Files
RuView/scripts/mediatek-csi-phase-stability.py
T
Mathew Beaneandclaude-flow ea6da967d2 docs(mediatek): OpenWrt WN586X3 CSI patch set, router CSI guide, ADR-266 amendment
- firmware/openwrt-wn586x3/: the patch set that made a Wavlink WL-WN586X3
  Rev A (MT7981B + MT7976C) emit CSI on OpenWrt 24.10.8. It contains:
  MediaTek's mt7915 CSI driver patch rebased onto the mt76 revision 24.10.8
  pins (BSD-3-Clause-Clear, MediaTek author line kept); the Rev A board
  patch (GPL-2.0); a buffer-overrun fix for `mt76-vendor dump csi`; and
  an attribute-enum fix for MtkCSIdump. Each patch keeps the licence of
  the code it modifies. There are no images, firmware blobs, or captures.
- docs/mediatek-router-csi.md: an operator guide covering supported
  silicon, the image build route that was tested, arming CSI, the
  bridge, `--source mediatek`, endpoints and provenance labels, what the
  data is not, evidence, and troubleshooting. Every claim is tagged
  MEASURED, CODE-DERIVED, CLAIMED, or SYNTHETIC.
- scripts/mediatek-csi-phase-stability.py: a read-only reproducer for
  the one MEASURED offline result. On one MT7976C radio, the cross-chain
  phase arg(H0*conj(H1)) moves 0.014-0.039 rad (p50) between adjacent
  frames for well-sampled transmitters, against about 1.56 rad for raw
  per-chain phase, which is what random phase gives. It never prints a
  transmitter address. The captures it was run on are not in the tree.
- ADR-266 Amendment 1: vendor CSI exists on MT7981 through MediaTek's
  public feed patch, demonstrated on one board model, and still
  `mediatek:physical-unvalidated`. The hardware gates remain open.
- user-guide.md and the bridge README link to the guide.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:32:25 -04:00

167 lines
6.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cross-chain phase stability of MediaTek mt76 vendor CSI (offline, read-only).
Reproducer for the MEASURED figures in docs/mediatek-router-csi.md. Input is a
capture made on the router by repeated `mt76-vendor <iface> dump csi <n> <file>`,
one dump (a JSON array of 13/14-element records) per line. Captures hold client
transmitter addresses and raw channel samples: keep them out of git. This script
never prints an address; transmitters are reported as TX-A, TX-B, ...
For every HT (rx_mode 2) frame that carries both receive chains of transmit
stream 0, it compares:
raw per-chain phase arg(H0[k]) between adjacent frames, and
cross cross-chain phase arg(H0[k] * conj(H1[k])), per bin and summed
coherently over bins.
CFO, SFO and the per-packet phase offset are common to both chains of one
radio, so they cancel in the cross term. Adjacent means the frames are under
100 ms apart. Steps are wrapped to [-pi, pi], so p99 can never exceed pi; read
p50 against pi/2 (1.571 rad), which is what uniformly random phase gives.
usage: python3 mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
"""
import json
import sys
import numpy as np
U32 = 1 << 32
MAX_DT = 0.1 # s; consecutive frames closer than this count as adjacent
GUARD = set(range(29, 36)) # BW20 bins 29..35: guard tones
VALID = np.array([k for k in range(1, 64) if k not in GUARD]) # DC excluded
KPHYS = np.where(VALID < 32, VALID, VALID - 64).astype(float)
def iter_lines(path, max_bytes=None):
read = 0
with open(path, "rb") as fh:
for raw in fh:
read += len(raw)
raw = raw.strip()
if raw:
try:
yield json.loads(raw)
except json.JSONDecodeError:
pass # truncated tail line
if max_bytes and read >= max_bytes:
return
def frames_from_line(recs, txidx):
"""Group a dump's records into PPDUs: per transmitter, consecutive records
sharing ts, closed on chain_info BIT15 or a repeated (tx, rx) slot."""
frames, open_ = [], {}
for r in recs:
ts = r[0] % U32
if r[1] not in txidx:
n = len(txidx)
txidx[r[1]] = "TX-" + chr(ord("A") + n) if n < 26 else f"TX-{n}"
tx = txidx[r[1]]
key = (r[7], r[8])
h = np.asarray(r[11], np.float64) + 1j * np.asarray(r[12], np.float64)
cur = open_.get(tx)
if cur is not None and (cur["ts"] != ts or key in cur["H"]):
frames.append(cur)
cur = None
if cur is None:
cur = {"tx": tx, "ts": ts, "rx_mode": r[6], "H": {}}
open_[tx] = cur
cur["H"][key] = h
if r[9] & (1 << 15):
frames.append(cur)
del open_[tx]
frames.extend(open_.values())
return frames
def load(path, max_bytes=None):
"""Per transmitter: unwrapped time (s) and chains (0,0), (0,1) of HT frames.
Within a dump the signed ts delta is used; between dumps the unsigned delta
mod 2^32, so a gap over 71.6 min between dumps would alias."""
txidx, per = {}, {}
t_unwrapped, prev_ts = None, None
for recs in iter_lines(path, max_bytes):
first = True
for f in frames_from_line(recs, txidx):
ts = f["ts"]
if prev_ts is None:
t_unwrapped = 0
elif first:
t_unwrapped += (ts - prev_ts) % U32
else:
t_unwrapped += ((ts - prev_ts + (U32 >> 1)) % U32) - (U32 >> 1)
prev_ts, first = ts, False
H = f["H"]
if (0, 0) not in H or (0, 1) not in H or f["rx_mode"] != 2:
continue
d = per.setdefault(f["tx"], {"t": [], "h0": [], "h1": []})
d["t"].append(t_unwrapped / 1e6)
d["h0"].append(H[(0, 0)][VALID].astype(np.complex64))
d["h1"].append(H[(0, 1)][VALID].astype(np.complex64))
out = {}
for tx, d in per.items():
t = np.asarray(d["t"])
srt = np.argsort(t, kind="stable")
out[tx] = {"t": t[srt], "h0": np.vstack(d["h0"])[srt], "h1": np.vstack(d["h1"])[srt]}
return out
def wrap(x):
return (x + np.pi) % (2 * np.pi) - np.pi
def sanitize(phase_rows):
"""Remove per-frame linear phase (offset + slope over subcarrier index)."""
order = np.argsort(KPHYS)
k = KPHYS[order]
u = np.unwrap(phase_rows[:, order], axis=1)
A = np.vstack([k, np.ones_like(k)]).T
coef, *_ = np.linalg.lstsq(A, u.T, rcond=None)
res = u - (A @ coef).T
out = np.empty_like(res)
out[:, order] = res
return out
def step_stats(phase, t):
dt = np.diff(t)
ok = (dt > 0) & (dt < MAX_DT)
st = np.abs(wrap(np.diff(phase, axis=0)))[ok].ravel()
if st.size == 0:
return None
p = np.percentile(st, [50, 90, 99])
return {"n": int(st.size), "p50": round(float(p[0]), 3), "p90": round(float(p[1]), 3),
"p99": round(float(p[2]), 3), "frac_gt_pi_2": round(float((st > np.pi / 2).mean()), 4)}
def analyse(d):
t, h0, h1 = d["t"], d["h0"].astype(complex), d["h1"].astype(complex)
raw0 = np.angle(h0)
x = h0 * np.conj(h1)
dt = np.diff(t)
adj = dt[(dt > 0) & (dt < MAX_DT)]
return {
"frames": int(len(t)),
"span_s": round(float(t[-1] - t[0]), 1),
"adjacent_pairs": int(adj.size),
"adjacent_dt_ms_p50": round(float(np.median(adj) * 1e3), 2) if adj.size else None,
"steps": {
"raw_chain0_per_bin": step_stats(raw0, t),
"sanitized_chain0_per_bin": step_stats(sanitize(raw0), t),
"cross_per_bin": step_stats(np.angle(x), t),
"cross_coherent_sum": step_stats(np.angle(x.sum(1)), t),
},
}
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
path = sys.argv[1]
mb = float(sys.argv[2]) if len(sys.argv) > 2 else None
data = load(path, int(mb * 1e6) if mb else None)
out = {"sampled_MB": mb or "all", "tx": {}}
for tx, d in sorted(data.items()):
if len(d["t"]) >= 50:
out["tx"][tx] = analyse(d)
print(json.dumps(out, indent=1))