fix(lf): detect_clock keys off the shortest well-supported interval, not the min

detect_clock estimated the bit clock from the *minimum* edge interval, so a few
stray sub-bit edges (which every real ADC capture carries) collapsed the estimate.
On a strongly-coupled, rail-clipping EM4100 the true half-bit of 32 dropped to ~3,
snapping the data clock to 8 and turning the ASK demod into all phase-error markers
— no config, no credential decoded.

Base the symbol on the shortest interval with real support (>=20% of the modal
count) instead of the raw minimum. Verified: clean synthetic still reads 32; the
hardware capture that broke it now clocks correctly. Live on a T5577 emulating
EM4100 00FFFFFFFF: DETECT decodes config 00148040, identify returns the credential.

Regression test reproduces the exact failure (sparse stray edges): old estimator
3.67 (dead demod), new 31.99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-16 17:59:14 -07:00
parent 8f136e7dc4
commit 8833753c45
2 changed files with 28 additions and 6 deletions

View File

@@ -55,23 +55,32 @@ def _edges(binary: list[int]) -> list[int]:
return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]]
def detect_clock(binary: list[int], min_run: int = 3) -> float | None:
def detect_clock(binary: list[int], min_run: int = 3, support: float = 0.2) -> float | None:
"""Estimate the shortest symbol width (samples) from the edge-interval distribution.
For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are
one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run``
are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster,
or ``None`` when there aren't enough clean edges to decide."""
or ``None`` when there aren't enough clean edges to decide.
The base symbol is the shortest interval with *real support* — a count at least ``support``×
the most-common interval's count — not the raw minimum. A real ADC capture always carries a
few stray sub-bit edges; keying off the bare minimum let a single 3-sample outlier collapse a
clean RF/64 tag's estimate from 32 to ~3 (hardware-observed on a strongly-coupled EM4100),
snapping the data clock to 8 and killing the demod. The dominant interval is robust to that."""
edges = _edges(binary)
if len(edges) < 4:
return None
intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run]
if len(intervals) < 3:
return None
intervals.sort()
base = intervals[0]
# average the cluster within 1.5x of the shortest interval -> the fundamental symbol width
cluster = [d for d in intervals if d <= base * 1.5]
counts: dict[int, int] = {}
for d in intervals:
counts[d] = counts.get(d, 0) + 1
floor = max(counts.values()) * support
base = min(d for d, n in counts.items() if n >= floor) # shortest well-supported interval
# average the cluster around the base (0.75x1.5x) -> the fundamental symbol width
cluster = [d for d in intervals if base * 0.75 <= d <= base * 1.5]
return sum(cluster) / len(cluster)

View File

@@ -88,6 +88,19 @@ class TestDSP:
half = dsp.detect_clock(dsp.binarize(env))
assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit
def test_clock_survives_sparse_stray_edges(self):
# hardware regression: a strongly-coupled EM4100 gives a clean RF/64 signal (intervals
# dominated by 32/64) with a few sub-bit stray edges from ADC ripple. The old min-based
# estimator keyed off the bare shortest interval and collapsed to ~3 (-> data clock 8 ->
# dead demod). The dominant interval is still 32, so detect_clock must ride through it.
env = bytearray(_em4100_env(0x0102030405, rf=64, high=255, low=0))
for i in range(200, len(env), 900): # sparse single-sample spikes
env[i] = 0 if env[i] > 128 else 255
half = dsp.detect_clock(dsp.binarize(bytes(env)))
assert half is not None and abs(half - 32) < 4 # not dragged down to the ~3 outlier
r = protocols.decode_em4100(bytes(env))
assert r and r["id_hex"] == "0102030405" # end-to-end decode survives
def test_manchester_roundtrip(self):
# (1,0)->1, (0,1)->0 at phase 0
assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1]