diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index ee3b3da94..282ae7b0a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -128,6 +128,7 @@ import com.k1af.ft8af.rigs.YaesuDX10Rig; import com.k1af.ft8af.spectrum.SpectrumListener; import com.k1af.ft8af.timer.ClockSelfSync; +import com.k1af.ft8af.timer.DecodeDtDisplay; import com.k1af.ft8af.timer.OnUtcTimer; import com.k1af.ft8af.timer.UtcTimer; import com.k1af.ft8af.ui.ToastMessage; @@ -185,6 +186,12 @@ private void fileLog(String msg) { // TimeSyncSettings can reset it when the operator flips the toggles. public final ClockSelfSync clockSelfSync = new ClockSelfSync(); + // Robust, smoothed source for the DT figure the UI shows (clock-sync pill + Time Sync + // readout). Always on — unlike clockSelfSync it never touches the clock, it only decides + // what number is honest to display. See DecodeDtDisplay for why the raw per-slot mean + // this replaced made a perfectly-synced rig look 2 s out. + public final DecodeDtDisplay decodeDtDisplay = new DecodeDtDisplay(); + //public CallsignDatabase callsignDatabase = null;//callsign information database public DatabaseOpr databaseOpr;//configuration info and related data database @@ -705,6 +712,14 @@ public void afterDecode(long utc, float time_sec, int sequential // already ignore own-callsign messages. OwnTxEchoFilter filtered = OwnTxEchoFilter.filter(decoded); ArrayList messages = filtered.kept; + // This pass's per-decode DTs (seconds). Feeds both the self-syncing clock + // (fast pass only, below) and the displayed DT (every pass), so the number + // on screen and the correction the estimator makes are measured from + // exactly the same samples. + final float[] dtSamples = new float[messages.size()]; + for (int i = 0; i < messages.size(); i++) { + dtSamples[i] = messages.get(i).time_sec; + } // Full duplex (satellite operating): our own downlink coming back // IS the measurement, so put the echoes the filter just dropped // back on screen. Strictly display — `display` feeds only the @@ -734,10 +749,6 @@ public void afterDecode(long utc, float time_sec, int sequential if (ClockSelfSync.mayRun(GeneralVariables.autoSyncClockFromDecodes, GeneralVariables.disciplineClockFromGPS, GeneralVariables.disciplineClockFromNtp)) { - float[] dtSamples = new float[messages.size()]; - for (int i = 0; i < messages.size(); i++) { - dtSamples[i] = messages.get(i).time_sec; - } // onSlot is atomic: slot dedup/ordering + streak update under // one lock, so concurrent adjacent-slot deliveries can't // interleave (out-of-order slots are rejected inside). @@ -804,12 +815,29 @@ public void afterDecode(long utc, float time_sec, int sequential appendToMessageList(display); publishFt8MessageList();//post an immutable snapshot so the UI recomposes immediately - // Slot-wide mean DT with own-TX echoes already excluded (see - // OwnTxEchoFilter.meanTimeOffsetSec). NaN can't happen here — messages - // is non-empty, so at least one decode survived — but never post it: - // the pill would render it as "unknown". - if (!Float.isNaN(time_sec)) { - mutableTimerOffset.postValue(time_sec);//this cycle's time offset + // Displayed DT. NOT the slot's raw mean (the `time_sec` argument, kept for + // the diagnostic below): a mean is at the mercy of one late station's DT, and + // it was re-posted on every decode pass, so the pill flashed "2.5 s off" on a + // rig that was working stations perfectly. DecodeDtDisplay applies the same + // robust measurement the self-syncing clock uses — MAD-rejected median per + // slot, accumulated across that slot's passes, then the median of the last + // few slots — and returns null when the reading hasn't meaningfully moved, + // which is what makes the indicator sit still when the clock is right. + Float shownDt = decodeDtDisplay.onDecodes(utc, dtSamples, UtcTimer.delay); + if (shownDt != null && !Float.isNaN(shownDt)) { + mutableTimerOffset.postValue(shownDt); + } + if (!isDeep) { + // Both numbers, once per slot: a debug.log where `raw` swings while + // `shown` holds is the smoothing doing its job, not a drifting clock. + fileLog(String.format(java.util.Locale.US, + "DT: raw=%+.2f shown=%s n=%d", + time_sec, + decodeDtDisplay.lastPublished() == null + ? "--" + : String.format(java.util.Locale.US, "%+.2f", + decodeDtDisplay.lastPublished()), + dtSamples.length)); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java new file mode 100644 index 000000000..a1118f8b5 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java @@ -0,0 +1,171 @@ +package com.k1af.ft8af.timer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Stabilises the decode-DT figure the operating screen shows (the clock-sync pill on the slot + * timer bar, and the "measured DT" readout in Settings → Time Sync). + * + *

Why this exists. The pill used to render the raw arithmetic mean of every + * decode in the slot, re-posted on every decode pass. Three things made that read as an unstable + * app on a rig that was in fact perfectly in time: + * + *

+ * + *

The estimator that actually corrects the clock ({@link ClockSelfSync}) has always + * been robust — median, MAD outlier rejection, multi-slot confirmation — so the display and the + * correction disagreed: the pill flashed "2.5 s off" while auto-sync, looking at the same decodes, + * correctly concluded there was nothing to do. This class puts the display on the same footing: + * + *

+ * + *

A deliberate clock change resets the window. When {@link UtcTimer#delay} moves — the + * operator applied a correction, or GPS/NTP/self-sync did — every stored sample describes the old + * clock. Holding them would average the correction away and make the pill lie for half a minute, + * so the history is dropped and the reading re-acquires from the next slot. + * + *

Pure Java, no Android imports, so it is unit-testable without Robolectric. The entry point is + * synchronized: decode passes for adjacent slots can deliver concurrently. + */ +public class DecodeDtDisplay { + + /** + * How many slots the displayed value is the median of. Three is the smallest window that + * outvotes a single bad slot while still following a real change within ~2 slots (30 s of FT8). + */ + public static final int WINDOW_SLOTS = 3; + + /** + * Minimum change (seconds) before a new reading is published. The pill renders one decimal, so + * anything under half a displayed digit is invisible movement that only costs a recomposition. + */ + public static final float PUBLISH_QUANTUM_SEC = 0.05f; + + /** Cap on the DTs retained for one slot, so a pathological slot can't grow without bound. */ + static final int MAX_SLOT_SAMPLES = 64; + + // The slot currently accumulating, and every DT seen for it across passes. + private long currentUtc = Long.MIN_VALUE; + private float[] currentSamples = new float[0]; + + // Robust medians of the most recently finished slots, oldest first, at most WINDOW_SLOTS - 1 + // (the in-progress slot supplies the newest value). + private final List finishedMedians = new ArrayList<>(); + + // Last value handed to the UI, for the publish deadband. Null = nothing published yet. + private Float published = null; + + // The clock offset the stored samples were measured against. + private Integer sampledAtDelayMs = null; + + /** + * Feed one decode pass's per-decode DTs (seconds, own-TX echoes already filtered out) and get + * the value the UI should now show, or {@code null} to keep showing what it has. + * + * @param utc the slot the decodes belong to; stragglers for an older slot are ignored + * @param dtSec this pass's DTs; may be empty + * @param clockDelayMs the live {@link UtcTimer#delay} — a change resets the window + */ + public synchronized Float onDecodes(long utc, float[] dtSec, int clockDelayMs) { + if (sampledAtDelayMs == null || sampledAtDelayMs != clockDelayMs) { + // The clock moved under us: everything measured before it describes a different clock. + resetHistory(); + sampledAtDelayMs = clockDelayMs; + } + if (utc < currentUtc) { + // A late pass for an already-superseded slot — stale evidence, ignore it. + return null; + } + if (utc > currentUtc) { + finishCurrentSlot(); + currentUtc = utc; + currentSamples = new float[0]; + } + appendSamples(dtSec); + + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return null; + } + float slotMedian = ClockSelfSync.medianOf(survivors); + + float[] window = new float[finishedMedians.size() + 1]; + for (int i = 0; i < finishedMedians.size(); i++) { + window[i] = finishedMedians.get(i); + } + window[window.length - 1] = slotMedian; + float display = ClockSelfSync.medianOf(window); + + if (published != null && Math.abs(display - published) < PUBLISH_QUANTUM_SEC) { + return null; + } + published = display; + return display; + } + + /** The value last handed to the UI, or null if nothing has been published yet. */ + public synchronized Float lastPublished() { + return published; + } + + /** Drops all history — the samples, the window, and the published value. */ + public synchronized void reset() { + resetHistory(); + sampledAtDelayMs = null; + } + + private void resetHistory() { + finishedMedians.clear(); + currentSamples = new float[0]; + currentUtc = Long.MIN_VALUE; + published = null; + } + + /** Retire the in-progress slot into the window (dropping the oldest once it is full). */ + private void finishCurrentSlot() { + float[] survivors = ClockSelfSync.rejectOutliers(currentSamples); + if (survivors.length == 0) { + return; + } + finishedMedians.add(ClockSelfSync.medianOf(survivors)); + while (finishedMedians.size() > WINDOW_SLOTS - 1) { + finishedMedians.remove(0); + } + } + + private void appendSamples(float[] dtSec) { + if (dtSec == null || dtSec.length == 0) { + return; + } + int room = MAX_SLOT_SAMPLES - currentSamples.length; + if (room <= 0) { + return; + } + int take = Math.min(room, dtSec.length); + float[] merged = Arrays.copyOf(currentSamples, currentSamples.length + take); + System.arraycopy(dtSec, 0, merged, currentSamples.length, take); + currentSamples = merged; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 877b736d1..b7bda4ec7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -319,8 +319,9 @@ fun FT8AFApp(mainViewModel: MainViewModel) { val operatingMode by mainViewModel.mutableOperatingMode.observeAsState(GeneralVariables.operatingMode) val modeName = ModeProfile.fromId(operatingMode).displayName - // Mean decode DT (seconds) for the slot-timer bar's live clock-sync pill; null until - // the first decode cycle reports one, so the bar renders unchanged before then. + // Decode DT (seconds) for the slot-timer bar's live clock-sync pill — the smoothed, + // outlier-rejected reading from DecodeDtDisplay, not a raw per-slot mean; null until the + // first decode cycle reports one, so the bar renders unchanged before then. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Observe SWR lockout state diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt index 3800f24e1..52ee333c0 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt @@ -29,15 +29,19 @@ import java.util.Locale import kotlin.math.abs /** - * Clock-sync health derived from the mean decode DT (time offset, in seconds). + * Clock-sync health derived from the smoothed decode DT (time offset, in seconds) — + * `DecodeDtDisplay`'s reading: an outlier-rejected median per slot, smoothed over the last few + * slots. It is deliberately not the slot's raw mean; see that class for why a mean made a rig + * that was working stations perfectly report itself seconds out of sync. * * FT8 tolerates only ~2 s of clock error before decoding collapses — both ways: a * mis-set clock stops you decoding others *and* pushes your transmissions to the edge * of everyone else's receive window so they can't decode you either. Yet the app never * surfaced this on the operating screen; the operator had to open Settings → Time Sync - * to notice. The mean DT of the stations you decode is a good proxy for your own clock + * to notice. The consensus DT of the stations you decode is a good proxy for your own clock * offset: most stations are themselves synced, so a consistent bias across all of them - * is almost certainly yours. + * is almost certainly yours — while any single station's DT is that station's own clock, which + * is why the displayed value is a median over several slots rather than one slot's average. */ internal enum class ClockSyncLevel { GOOD, FAIR, POOR, UNKNOWN } @@ -48,7 +52,7 @@ internal const val CLOCK_SYNC_GOOD_SEC = 0.3f internal const val CLOCK_SYNC_FAIR_SEC = 1.0f /** - * Classify the mean decode DT into a sync-health level. A `null` value means no decode + * Classify the smoothed decode DT into a sync-health level. A `null` value means no decode * cycle has reported yet this session; a non-finite value is treated the same way so a bad * reading can never mis-color the pill. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt index 301744a5e..9698ee7d7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt @@ -40,7 +40,7 @@ internal fun stepCorrectionMs(current: Int, deltaMs: Int): Int = clampCorrectionMs(current + deltaMs) /** - * Suggest a new correction from the average decode DT of the stations we're + * Suggest a new correction from the smoothed decode DT of the stations we're * hearing — the only time reference available offline. * * `avgDtSec` is WSJT-style DT (seconds): how far into our RX window the decoded diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt index 70db3e1dc..3a177928c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt @@ -96,8 +96,8 @@ fun TimeSyncSettings( GeneralVariables.mutableNtpClockSyncFailed.value ) - // Latest cycle's average decode DT (seconds), posted in MainViewModel.afterDecode. - // Null until the first decode this session. + // Smoothed decode DT (seconds) — DecodeDtDisplay's reading, posted in + // MainViewModel.afterDecode. Null until the first decode this session. val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() // Last self-sync correction (timestamp), so the Suggestion card can show that diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java new file mode 100644 index 000000000..56ef8c79d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/DecodeDtDisplayTest.java @@ -0,0 +1,153 @@ +package com.k1af.ft8af.timer; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link DecodeDtDisplay} — the robust, smoothed source for the DT figure shown on + * the operating screen. Pure Java, no Robolectric needed. + * + *

The behaviour under test is the fix for "my DT indicator says 2.5 s off while the rig works + * stations perfectly": one station's DT, or one marginal deep-pass decode, must not be allowed to + * move the displayed number, while a real, sustained clock error still must. + */ +public class DecodeDtDisplayTest { + + private static final int DELAY = 0; + + /** Slot boundaries only have to be increasing; real ones are UTC ms. */ + private static long slot(int n) { + return 1_700_000_000_000L + n * 15_000L; + } + + @Test + public void firstSlotPublishesImmediately() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.4f, 0.5f, 0.6f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.5f); + } + + @Test + public void oneLateStationCannotDragTheReading() { + // The exact shape of the complaint: three in-time decodes and one station 7 s late. + // The old arithmetic mean of these is +1.85 s (a red "clock off" verdict). + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float shown = dt.onDecodes(slot(1), new float[]{0.1f, 0.0f, 0.2f, 7.0f}, DELAY); + assertThat(shown).isNotNull(); + assertThat(Math.abs(shown)).isLessThan(0.3f); + } + + @Test + public void deepPassRefinesTheSlotInsteadOfReplacingIt() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + Float fast = dt.onDecodes(slot(1), new float[]{0.1f, 0.1f, 0.2f}, DELAY); + assertThat(fast).isNotNull(); + // A deep pass for the SAME slot digs out one marginal decode with a wild DT. It joins the + // slot's samples (where it is rejected) rather than becoming the new reading. + Float deep = dt.onDecodes(slot(1), new float[]{2.4f}, DELAY); + assertThat(deep).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(fast); + } + + @Test + public void oneFreakSlotIsOutvotedByTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.1f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A whole slot whose only decode is a late station: the median of the last three slots + // still reads the healthy clock. + Float shown = dt.onDecodes(slot(3), new float[]{3.0f}, DELAY); + assertThat(shown).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void sustainedErrorIsStillFollowed() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.0f, 0.0f}, DELAY); + dt.onDecodes(slot(2), new float[]{0.0f, 0.0f}, DELAY); + // The clock genuinely drifts: every station now lands a second late. + dt.onDecodes(slot(3), new float[]{1.0f, 1.0f}, DELAY); + dt.onDecodes(slot(4), new float[]{1.0f, 1.0f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.0f); + } + + @Test + public void tinyChangesAreHeldSoTheLastDigitStopsTwitching() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[]{0.10f}, DELAY)).isNotNull(); + assertThat(dt.onDecodes(slot(2), new float[]{0.12f}, DELAY)).isNull(); + assertThat(dt.onDecodes(slot(3), new float[]{0.11f}, DELAY)).isNull(); + } + + @Test + public void aClockCorrectionResetsTheWindow() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{1.2f}, DELAY); + dt.onDecodes(slot(2), new float[]{1.2f}, DELAY); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(1.2f); + // Auto-sync (or the operator) trims the clock by 1.2 s: the stored samples describe the + // old clock, so the next slot re-acquires on its own instead of being averaged with them. + Float shown = dt.onDecodes(slot(3), new float[]{0.0f}, DELAY - 1200); + assertThat(shown).isNotNull(); + assertThat(shown).isWithin(1e-4f).of(0.0f); + } + + @Test + public void stragglerForAnOlderSlotIsIgnored() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(2), new float[]{0.1f}, DELAY); + // A late pass for the previous slot arriving after its successor: stale evidence. + assertThat(dt.onDecodes(slot(1), new float[]{4.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.1f); + } + + @Test + public void emptyPassPublishesNothing() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + assertThat(dt.onDecodes(slot(1), new float[0], DELAY)).isNull(); + assertThat(dt.onDecodes(slot(2), null, DELAY)).isNull(); + assertThat(dt.lastPublished()).isNull(); + } + + @Test + public void windowOnlyRemembersTheMostRecentSlots() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + // Three slots of a real +2 s error, then the clock is fixed and stays fixed. Once the + // window has rolled past the bad slots the reading must be the new truth, not a blend. + for (int i = 1; i <= 3; i++) { + dt.onDecodes(slot(i), new float[]{2.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(2.0f); + for (int i = 4; i <= 3 + DecodeDtDisplay.WINDOW_SLOTS; i++) { + dt.onDecodes(slot(i), new float[]{0.0f}, DELAY); + } + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.0f); + } + + @Test + public void slotSamplesAreCappedAcrossPasses() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + float[] batch = new float[DecodeDtDisplay.MAX_SLOT_SAMPLES]; + for (int i = 0; i < batch.length; i++) { + batch[i] = 0.2f; + } + assertThat(dt.onDecodes(slot(1), batch, DELAY)).isNotNull(); + // Past the cap, further passes for the same slot are dropped rather than growing forever; + // the reading stands on what it already has. + assertThat(dt.onDecodes(slot(1), new float[]{9.0f, 9.0f, 9.0f}, DELAY)).isNull(); + assertThat(dt.lastPublished()).isWithin(1e-4f).of(0.2f); + } + + @Test + public void resetClearsEverything() { + DecodeDtDisplay dt = new DecodeDtDisplay(); + dt.onDecodes(slot(1), new float[]{0.7f}, DELAY); + dt.reset(); + assertThat(dt.lastPublished()).isNull(); + Float shown = dt.onDecodes(slot(2), new float[]{-0.4f}, DELAY); + assertThat(shown).isWithin(1e-4f).of(-0.4f); + } +}