Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Ft8Message> 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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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));
}


Expand Down
171 changes: 171 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/timer/DecodeDtDisplay.java
Original file line number Diff line number Diff line change
@@ -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 &rarr; Time Sync).
*
* <p><b>Why this exists.</b> The pill used to render the raw arithmetic <em>mean</em> 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:
*
* <ul>
* <li><b>A mean has no defence against one bad decode.</b> DT is per-station: a station
* transmitting late, or a marginal decode the searcher placed a couple of seconds off,
* carries its own big DT. Average three good decodes at +0.1 s with one at +7 s and the
* pill announces "+1.8 s" — a red "clock off" verdict produced entirely by somebody
* else's clock.</li>
* <li><b>Every pass re-posted.</b> A slot is decoded several times (fast pass, then deep and
* late passes that dig out the weakest signals — exactly the decodes with the sloppiest
* DT), so the number visibly jumped several times within one 15 s slot.</li>
* <li><b>No slot-to-slot smoothing.</b> Even with clean data, a slot with two decodes bounces
* against a slot with nine.</li>
* </ul>
*
* <p>The estimator that actually <em>corrects</em> 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:
*
* <ul>
* <li>Per slot: MAD-based outlier rejection then the <em>median</em> of the survivors, reusing
* {@link ClockSelfSync}'s helpers so both paths measure the same way.</li>
* <li>Passes accumulate into the slot they belong to instead of each replacing the reading, so a
* deep pass refines the slot's median rather than yanking the display.</li>
* <li>Across slots: the median of the last {@link #WINDOW_SLOTS} slots, so a single freak slot
* cannot move the needle at all.</li>
* <li>A {@link #PUBLISH_QUANTUM_SEC} deadband on the published value, so the last digit stops
* twitching between otherwise identical readings.</li>
* </ul>
*
* <p><b>A deliberate clock change resets the window.</b> 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.
*
* <p>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<Float> 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;
}
}
14 changes: 11 additions & 3 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -602,7 +603,14 @@ fun FT8AFApp(mainViewModel: MainViewModel) {
showCatChip = showCatChip,
catState = catState,
onReconnectCat = onReconnectCatAction,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
// Bottom padding separates the status line from the sheet's rounded top edge
// below it — butted together, the rounding read as if the line were clipped.
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 10.dp,
),
)
Spacer(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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.
*/
Expand Down
Loading
Loading