diff --git a/flicker_burst.py b/flicker_burst.py new file mode 100644 index 0000000..347e7a9 --- /dev/null +++ b/flicker_burst.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +""" +flicker_burst.py — Press `f` when you observe flicker. The script then: + + 1. Arms Keysight DSO80204B for a large segmented MIPI capture (LP_DAT + trigger fires at line rate, ~48 kHz, so segments fill in ms). + 2. Polls SN65 /sn65_registers continuously at ~50 Hz, recording every + PLL state transition. + 3. Tails video_cycler.py's CSV log and stops capturing the moment + the next video stop/start transition is observed (i.e. the end of + the current video-on window). + 4. Reads out all Keysight segments and saves everything to a + per-burst folder for offline signal-integrity / protocol analysis. + +Run alongside video_cycler.py in another terminal: + + Terminal A: python3 video_cycler.py # provokes flicker + Terminal B: python3 flicker_burst.py # this script + (press `f` when you see flicker; `q` to quit) + +Output: + data/flicker_bursts/{session_ts}/ + burst_NNNN_{ts}_pll_samples.json + burst_NNNN_{ts}_mipi_seg001_clk.csv ... segNNN_dat.csv + burst_NNNN_{ts}_meta.json + summary.csv +""" + +from __future__ import annotations + +import argparse +import csv +import json +import select +import signal +import sys +import termios +import time +import tty +from datetime import datetime +from pathlib import Path + +import numpy as np +import requests +import vxi11 + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DEVICE_BASE = "http://192.168.45.8:5000" +SN65_EP = f"{DEVICE_BASE}/sn65_registers" +KEYSIGHT_IP = "192.168.45.4" +RIGOL_IP = "192.168.45.5" +DATA_ROOT = Path(__file__).parent / "data" / "flicker_bursts" +CYCLE_LOG_DIR = Path(__file__).parent / "data" / "cycle_logs" + +POLL_DT_S = 0.020 # 50 Hz SN65 polling +HTTP_TO_S = 0.2 +KEYSIGHT_TO_S = 60.0 # large reads can take a while +RIGOL_TO_S = 10.0 + +# Rigol CH1 (1V8 supply rail) — wide enough to bracket the whole burst window +RIGOL_V_SCALE = 0.1 # V/div +RIGOL_V_OFFSET = -1.8 # V (puts 1.8 V at screen centre) +RIGOL_TIMEBASE = 1.0 # s/div → 12 s window +RIGOL_PROBE = 10 + +# Keysight LP_DAT segmented capture — large segment count. Segments fill in +# ms (line rate ≈ 48 kHz × N segs), but readout is the slow part: each +# segment is one SCPI round-trip per channel. 500 segs ≈ ~30 s readout. +KS_LP_SCALE = 1e-6 +KS_LP_POINTS = 50_000 +KS_LP_TRIG_OFFSET = 9e-6 +KS_LP_V_SCALE = 0.2 +KS_LP_V_OFFSET = 0.6 +KS_LP_TRIG_LEVEL = 0.6 +KS_SEGMENT_COUNT = 100 # readout ~6 s (was 500 → ~30 s) +KS_PROBE = 19.2 + +# Safety: cap any single capture at this long, in case video_cycler isn't +# running or its log isn't updating. +MAX_CAPTURE_S = 20.0 + +ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err", + "cha_ecc_err", "cha_lp_err", "cha_crc_err") + + +# --------------------------------------------------------------------------- +# Non-blocking keys +# --------------------------------------------------------------------------- +class KeyReader: + def __enter__(self): + self.fd = sys.stdin.fileno() + self.old = termios.tcgetattr(self.fd) + tty.setcbreak(self.fd) + return self + + def get_key(self) -> str | None: + if select.select([sys.stdin], [], [], 0)[0]: + return sys.stdin.read(1).lower() + return None + + def __exit__(self, *_): + termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old) + + +# --------------------------------------------------------------------------- +# CSV-log tail for video_cycler +# --------------------------------------------------------------------------- +class CyclerLogTail: + """ + Watch video_cycler.py's most-recent CSV log for new events. + + Uses stat-based size tracking and fresh opens on every check so we're + immune to any TextIOWrapper buffering quirks across processes. + """ + + def __init__(self): + self.path: Path | None = None + self.pos: int = 0 # byte offset we've read up to + self._find_latest(initial=True) + + def _find_latest(self, initial: bool = False) -> bool: + logs = sorted(CYCLE_LOG_DIR.glob("*_cycles.csv")) if CYCLE_LOG_DIR.exists() else [] + if not logs: + return False + latest = logs[-1] + if self.path != latest: + self.path = latest + try: + # Skip past whatever was already in the file at startup — + # we only want NEW events. Subsequent rolls keep pos=0. + self.pos = self.path.stat().st_size if initial else 0 + except FileNotFoundError: + self.pos = 0 + return True + + def get_next_event(self, timeout_s: float) -> dict | None: + """ + Wait up to timeout_s for the next start/stop event. + Returns {'iso','ts','event','cycle'} or None. + """ + self._find_latest() + if not self.path: + return None + + deadline = time.time() + timeout_s + first = True + while first or time.time() < deadline: + first = False + try: + size = self.path.stat().st_size + except FileNotFoundError: + self._find_latest() + if timeout_s <= 0: + return None + time.sleep(0.05) + continue + if size > self.pos: + try: + with open(self.path, "r") as f: + f.seek(self.pos) + line = f.readline() + self.pos = f.tell() + except Exception: + line = "" + if line: + parts = [p.strip() for p in line.strip().split(",")] + if len(parts) >= 4 and parts[0] != "iso": + try: + return {"iso": parts[0], "ts": float(parts[1]), + "event": parts[2], "cycle": int(parts[3])} + except Exception: + pass + # Whitespace/comment line — keep looping + continue + if timeout_s <= 0: + return None + self._find_latest() + time.sleep(0.05) + return None + + +# --------------------------------------------------------------------------- +# SN65 extraction +# --------------------------------------------------------------------------- +def extract_state(data: dict | None) -> dict: + regs = (data or {}).get("registers", {}) or {} + csr_0a = regs.get("csr_0a") or {} + csr_e5 = regs.get("csr_e5") or {} + out = { + "csr_0a": csr_0a.get("value"), + "csr_e5": csr_e5.get("value"), + "pll_lock": csr_0a.get("pll_lock"), + "clk_det": csr_0a.get("clk_det"), + } + for k in ERROR_BITS: + out[k] = csr_e5.get(k) + return out + + +# --------------------------------------------------------------------------- +# Rigol I/O (1V8 supply rail capture) +# --------------------------------------------------------------------------- +def setup_rigol(rigol) -> None: + rigol.write(":STOP"); time.sleep(0.2) + rigol.write(":CHANnel1:DISPlay 1") + rigol.write(":CHANnel1:COUPling DC") + rigol.write(f":CHANnel1:PROBe {RIGOL_PROBE}") + rigol.write(f":CHANnel1:SCALe {RIGOL_V_SCALE:.3f}") + rigol.write(f":CHANnel1:OFFSet {RIGOL_V_OFFSET:.3f}") + rigol.write(":CHANnel2:DISPlay 0") + rigol.write(f":TIMebase:MAIN:SCALe {RIGOL_TIMEBASE:.3E}") + rigol.write(":TRIGger:MODE EDGE") + rigol.write(":TRIGger:EDGe:SOURce CHANnel1") + rigol.write(":TRIGger:EDGe:SLOPe NEGative") + rigol.write(":TRIGger:EDGe:LEVel 1.76") + rigol.write(":TRIGger:SWEep AUTO") + rigol.write(":ACQuire:MDEPth AUTO") + time.sleep(0.3); rigol.write(":RUN"); time.sleep(0.2) + + +def capture_rail(rigol, out_path: Path) -> tuple[float, float]: + rigol.write(":STOP"); time.sleep(0.1) + rigol.write(":WAVeform:SOURce CHANnel1") + rigol.write(":WAVeform:FORMat ASC") + rigol.write(":WAVeform:MODE NORM") + time.sleep(0.05) + pre = rigol.ask(":WAVeform:PREamble?").strip().split(",") + xinc = float(pre[4]); xorig = float(pre[5]) + raw = rigol.ask(":WAVeform:DATA?").strip() + if raw.startswith("#"): + ndig = int(raw[1]) + raw = raw[2 + ndig:] + vals = [float(v) for v in raw.split(",") if v.strip()] + if not vals: + rigol.write(":RUN") + raise RuntimeError("Rigol returned no samples") + volts = np.asarray(vals, dtype=np.float64) + t = np.arange(len(volts)) * xinc + xorig + np.savetxt(out_path, np.column_stack([t, volts]), + delimiter=",", fmt="%.6e") + rigol.write(":RUN") + return float((volts.max() - volts.min()) * 1000), float(volts.mean()) + + +# --------------------------------------------------------------------------- +# Keysight I/O +# --------------------------------------------------------------------------- +def _ks_drain(scope): + for _ in range(20): + try: + r = scope.ask(":SYSTem:ERRor?").strip() + except Exception: + return + if not r or r.startswith(("0,", "+0,")) or r == "0": + return + + +def setup_keysight(scope) -> None: + for c in [ + "*RST", ":RUN", ":STOP", "*CLS", + ":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50", + f":CHANnel1:PROBe {KS_PROBE}", ":CHANnel1:LABel 'CLK+'", + ":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50", + f":CHANnel2:PROBe {KS_PROBE}", ":CHANnel2:LABel 'CLK-'", + ":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50", + f":CHANnel3:PROBe {KS_PROBE}", ":CHANnel3:LABel 'DAT0+'", + ":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50", + f":CHANnel4:PROBe {KS_PROBE}", ":CHANnel4:LABel 'DAT0-'", + ":TIMebase:REFerence CENTer", + ":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON", + ]: + scope.write(c); time.sleep(0.04) + _ks_drain(scope) + for ch in (1, 2, 3, 4): + scope.write(f":CHANnel{ch}:SCALe {KS_LP_V_SCALE:.3f}") + scope.write(f":CHANnel{ch}:OFFSet {KS_LP_V_OFFSET:.3f}") + scope.write(":TRIGger:MODE EDGE") + scope.write(":TRIGger:EDGE:SOURce CHANnel3") + scope.write(":TRIGger:EDGE:SLOPe NEGative") + scope.write(f":TRIGger:EDGE:LEVel {KS_LP_TRIG_LEVEL:.3f}") + scope.write(":TRIGger:SWEep NORMal") + scope.write(f":TIMebase:SCALe {KS_LP_SCALE:.3E}") + scope.write(f":ACQuire:POINts {KS_LP_POINTS}") + scope.write(f":TIMebase:POSition {KS_LP_TRIG_OFFSET:.2E}") + scope.write(":ACQuire:MODE SEGMented") + scope.write(f":ACQuire:SEGMented:COUNt {KS_SEGMENT_COUNT}") + time.sleep(0.4) + _ks_drain(scope) + + +def _ks_read_block(scope) -> bytes: + head = scope.read_raw(2) + if not head.startswith(b"#"): + idx = head.find(b"#") + if idx < 0: + extra = scope.read_raw(64) + head += extra + idx = head.find(b"#") + head = head[idx:idx + 2] + ndigits = int(head[1:2]) + length_bytes = scope.read_raw(ndigits) + nbytes = int(length_bytes) + data = b"" + while len(data) < nbytes: + chunk = scope.read_raw(nbytes - len(data)) + if not chunk: + break + data += chunk + try: + scope.read_raw(1) + except Exception: + pass + return data + + +def keysight_arm(scope) -> None: + """Send :DIGitize. Acquisition runs in scope memory.""" + scope.write(":DIGitize") + + +def keysight_read_segments(scope, n_segments: int, out_dir: Path, + base: str) -> int: + """Read N segments for both channels, save per-segment CSVs.""" + n_written = 0 + for chan_id, label in [(1, "clk"), (3, "dat")]: + scope.write(f":WAVeform:SOURce CHANnel{chan_id}") + scope.write(":WAVeform:FORMat WORD") + scope.write(":WAVeform:BYTeorder LSBFirst") + x_inc = float(scope.ask(":WAVeform:XINCrement?")) + x_org = float(scope.ask(":WAVeform:XORigin?")) + y_inc = float(scope.ask(":WAVeform:YINCrement?")) + y_org = float(scope.ask(":WAVeform:YORigin?")) + for i in range(1, n_segments + 1): + scope.write(f":ACQuire:SEGMented:INDex {i}") + scope.write(":WAVeform:DATA?") + raw = _ks_read_block(scope) + codes = np.frombuffer(raw, dtype=" None: + """One full burst: arm scope → poll SN65 → wait for cycler event → + read MIPI segments → save everything.""" + t_press = time.time() + iso_press = datetime.fromtimestamp(t_press).strftime("%H:%M:%S.%f")[:-3] + ts_press = datetime.fromtimestamp(t_press).strftime("%Y%m%d_%H%M%S_%f")[:-3] + base = f"burst_{burst_n:04d}_{ts_press}" + print(f"\n [{iso_press}] FLICKER #{burst_n} — capture started", flush=True) + + # 1. Arm Keysight + if scope is not None: + try: + keysight_arm(scope) + except Exception as e: + print(f" Keysight arm FAILED: {e}", flush=True) + + # 2. Poll SN65 in main thread while also tailing cycler log + samples: list = [] + unlocks: list = [] + last_pll = None + end_event = None + deadline = t_press + MAX_CAPTURE_S + next_log_check = 0.0 # only check log every ~50 ms to keep poll rate high + + while time.time() < deadline: + t0 = time.time() + try: + r = sess.get(SN65_EP, timeout=HTTP_TO_S) + r.raise_for_status() + state = extract_state(r.json()) + samples.append({"ts": t0, "state": state}) + pll = state["pll_lock"] + if last_pll is True and pll is False: + unlocks.append({"ts": t0, + "iso": datetime.fromtimestamp(t0) + .strftime("%H:%M:%S.%f")[:-3]}) + if pll is not None: + last_pll = pll + except Exception as e: + samples.append({"ts": t0, "error": str(e)}) + + # Cheap check (non-blocking) of cycler log + if t0 >= next_log_check: + ev = cycler_tail.get_next_event(timeout_s=0.0) + if ev is not None and ev["ts"] > t_press: + end_event = ev + break + next_log_check = t0 + 0.05 # 20 Hz log check + + # Pace SN65 polling + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + + t_end = time.time() + end_iso = datetime.fromtimestamp(t_end).strftime("%H:%M:%S.%f")[:-3] + end_reason = ("cycler_event:" + end_event["event"]) if end_event else "timeout" + print(f" [{end_iso}] capture window ended ({end_reason}) — " + f"polled {len(samples)} samples in {t_end - t_press:.2f}s", + flush=True) + + # 3a. Rigol 1V8 rail snapshot (fast — ~300 ms) + rail_vpp_mV = rail_mean_V = None + rail_path = None + if rigol is not None: + rail_path = session_dir / f"{base}_rail.csv" + try: + rail_vpp_mV, rail_mean_V = capture_rail(rigol, rail_path) + print(f" rail: Vpp={rail_vpp_mV:.1f}mV mean={rail_mean_V:.3f}V " + f"({RIGOL_TIMEBASE*12:.0f}s window)", flush=True) + except Exception as e: + print(f" rail capture FAILED: {e}", flush=True) + rail_path = None + + # 3b. Read Keysight segments + n_segs = 0 + if scope is not None: + try: + # Wait briefly for :DIGitize to complete (segments fill in ms at + # line rate, but allow margin) + prev = scope.timeout + try: + scope.timeout = 10 + opc = scope.ask("*OPC?").strip() + except Exception: + opc = "0" + finally: + scope.timeout = prev + if opc != "1": + print(f" Keysight :DIGitize didn't complete (OPC={opc}) — " + f"attempting read anyway", flush=True) + print(f" reading {KS_SEGMENT_COUNT} segments ×2 ch — be patient", + flush=True) + t_read0 = time.time() + n_segs = keysight_read_segments( + scope, KS_SEGMENT_COUNT, session_dir, base + "_mipi") + print(f" MIPI: {n_segs} segments saved " + f"(readout took {time.time() - t_read0:.1f}s)", flush=True) + except Exception as e: + print(f" Keysight read FAILED: {e}", flush=True) + + # 4. Pair unlocks with their recovery times + unlock_pairs = [] + pll_evts = [s for s in samples + if "state" in s and s["state"].get("pll_lock") is not None] + for u in unlocks: + for s in pll_evts: + if s["ts"] > u["ts"] and s["state"]["pll_lock"] is True: + unlock_pairs.append({"start_ts": u["ts"], "start_iso": u["iso"], + "duration_ms": (s["ts"] - u["ts"]) * 1000}) + break + + # 5. Save samples + meta + samples_path = session_dir / f"{base}_pll_samples.json" + samples_path.write_text(json.dumps({ + "burst": burst_n, + "t_press": t_press, + "press_iso": iso_press, + "t_end": t_end, + "end_iso": end_iso, + "end_reason": end_reason, + "end_event": end_event, + "duration_s": t_end - t_press, + "n_samples": len(samples), + "n_unlocks": len(unlock_pairs), + "unlock_pairs": unlock_pairs, + "samples": samples, + }, indent=2, default=str)) + + meta_path = session_dir / f"{base}_meta.json" + meta_path.write_text(json.dumps({ + "burst": burst_n, + "t_press": t_press, + "press_iso": iso_press, + "t_end": t_end, + "end_iso": end_iso, + "end_reason": end_reason, + "duration_s": t_end - t_press, + "n_pll_samples": len(samples), + "n_unlocks": len(unlock_pairs), + "mipi_basename": f"{base}_mipi" if n_segs else None, + "n_mipi_segments": n_segs, + "ks_lp_scale_s": KS_LP_SCALE, + "ks_lp_points": KS_LP_POINTS, + "rail_csv": rail_path.name if rail_path else None, + "rail_vpp_mV": rail_vpp_mV, + "rail_mean_V": rail_mean_V, + "rail_window_s": RIGOL_TIMEBASE * 12, + }, indent=2, default=str)) + + summary_writer.writerow([burst_n, ts_press, iso_press, end_iso, + f"{t_end - t_press:.2f}", end_reason, + len(samples), len(unlock_pairs), n_segs, + f"{rail_vpp_mV:.1f}" if rail_vpp_mV is not None else "", + f"{rail_mean_V:.3f}" if rail_mean_V is not None else "", + base]) + + durs = sorted(p["duration_ms"] for p in unlock_pairs) + if durs: + n = len(durs) + print(f" unlocks during burst: {n} " + f"min={durs[0]:.1f}ms med={durs[n//2]:.1f}ms " + f"max={durs[-1]:.1f}ms", flush=True) + else: + print(f" unlocks during burst: 0", flush=True) + print(f" saved {base}_*", flush=True) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--no-keysight", action="store_true", + help="SN65 polling only (skip MIPI capture)") + ap.add_argument("--no-rigol", action="store_true", + help="skip Rigol 1V8 rail capture") + args = ap.parse_args() + + session_ts = datetime.now().strftime("%Y%m%d_%H%M%S") + session_dir = DATA_ROOT / session_ts + session_dir.mkdir(parents=True, exist_ok=True) + + print(f"FLICKER BURST CAPTURE — session {session_ts}") + print(f" output: {session_dir.relative_to(DATA_ROOT.parent.parent)}") + + sess = requests.Session() + try: + sess.get(SN65_EP, timeout=2.0).raise_for_status() + print(f" SN65: reachable") + except Exception as e: + print(f" *** SN65 endpoint failed: {e} ***") + sys.exit(1) + + rigol = None + if not args.no_rigol: + try: + rigol = vxi11.Instrument(RIGOL_IP) + rigol.timeout = RIGOL_TO_S + idn = rigol.ask("*IDN?").strip() + print(f" Rigol: {idn}") + setup_rigol(rigol) + print(f" CH1 1V8 rail, {RIGOL_V_SCALE*1000:.0f} mV/div, " + f"{RIGOL_TIMEBASE:.1f} s/div ({RIGOL_TIMEBASE*12:.0f}s window)") + except Exception as e: + print(f" Rigol failed ({e}) — continuing without rail capture") + rigol = None + else: + print(f" Rigol: disabled (--no-rigol)") + + scope = None + if not args.no_keysight: + try: + scope = vxi11.Instrument(KEYSIGHT_IP) + scope.timeout = KEYSIGHT_TO_S + idn = scope.ask("*IDN?").strip() + print(f" Keysight: {idn}") + setup_keysight(scope) + print(f" LP_DAT segmented, {KS_SEGMENT_COUNT} segs/acq, " + f"{KS_LP_POINTS} pts × {KS_LP_SCALE*1e6:.0f} µs/div") + except Exception as e: + print(f" Keysight failed ({e}) — continuing without MIPI") + scope = None + else: + print(f" Keysight: disabled (--no-keysight)") + + cycler_tail = CyclerLogTail() + if cycler_tail.path: + print(f" cycler log: {cycler_tail.path.name} (tailing for STOP events)") + else: + print(f" cycler log: NOT FOUND — capture will use {MAX_CAPTURE_S}s timeout per burst") + + summary_path = session_dir / "summary.csv" + sf = open(summary_path, "w", newline="") + sw = csv.writer(sf) + sw.writerow(["burst", "ts", "iso_press", "iso_end", "duration_s", + "end_reason", "n_pll_samples", "n_unlocks", + "n_mipi_segs", "rail_vpp_mV", "rail_mean_V", "basename"]) + sf.flush() + + def _shutdown(*_): + try: sf.close() + except Exception: pass + print("\nshutting down") + sys.exit(0) + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + print("\n Press `f` when you see flicker. `q` to quit.") + print(" Each press triggers a capture window from now until video_cycler") + print(f" next stops the video (or {MAX_CAPTURE_S:.0f}s timeout if no cycler).\n") + + burst_n = 0 + with KeyReader() as keys: + while True: + key = keys.get_key() + if key == "q": + _shutdown() + elif key == "f": + burst_n += 1 + capture_burst(sess, scope, rigol, cycler_tail, + burst_n, session_dir, sw) + sf.flush() + print(f"\n ready for next press...\n", flush=True) + else: + time.sleep(0.05) + + +if __name__ == "__main__": + main() diff --git a/flicker_investigation_report_v2.html b/flicker_investigation_report_v2.html new file mode 100644 index 0000000..966f7d8 --- /dev/null +++ b/flicker_investigation_report_v2.html @@ -0,0 +1,179 @@ + + + + +MIPI DSI Flicker — Root Cause Investigation + + + + + +
+

MIPI DSI Flicker — Hardware Exoneration Test

+
Session 20260515_135656  ·  Report generated 2026-05-15 16:26  ·  11 operator-confirmed flicker observations analysed
+
+TL;DR   Across 11 operator-confirmed flicker observations, 2 (18%) produced detectable SN65 PLL unlocks; the remaining 9 (82%) showed no measurable change in SN65 register state, 1V8 supply rail, or MIPI clock signal. Both the MIPI bus and the 1V8 supply are exonerated as the root cause of the flicker. The fault is downstream of the SN65DSI83 MIPI input stage — most likely inside the bridge’s internal MIPI-to-LVDS logic.
+

1. Method

+

The flicker_burst.py tool was run alongside video_cycler.py. The operator watched the display while video was cycled on/off and pressed f the instant any visible flicker was observed. Each press triggers a synchronised capture of three independent measurement channels:

+ + + + +
ChannelInstrumentWhat it captures
SN65 PLL state & error bitsHTTP / I2CContinuous polling at ~50 Hz from f-press until video_cycler’s next stop event
1V8 supply railRigol DS1202Z-E (CH1)12 s window (10 ms/div × 12), 100 mV/div, −1.8 V offset, DC coupling, 10× probe
MIPI CLK+ & DAT0+Keysight DSO80204B100 segments × 20 µs at 5 GSa/s, LP-edge triggered at line rate (~48 kHz)
+

2. Per-burst SN65 register summary

+ + + + + + + + + + + + +
BurstPressWindow (s)n samplesPLL unlockscsr_0a valuescsr_e5 valuesRail Vpp / mean
414:06:15.7722.1710300x85=1030x00=103120 mV / 1764.6 mV
514:25:13.60013.396271 (20.3 ms)0x85=624, None=2, 0x05=10x00=622, None=3, 0x01=2124 mV / 1764.0 mV
814:32:00.1255.1624600x85=2460x00=246120 mV / 1765.4 mV
1114:42:54.5496.212831 (35.3 ms)0x85=279, None=3, 0x0a=10x00=278, None=3, 0x01=2128 mV / 1765.1 mV
1314:52:17.0558.7541400x85=4140x00=414128 mV / 1764.8 mV
1414:58:48.7619.3644800x85=4480x00=447, None=1120 mV / 1764.8 mV
1515:03:20.9349.6246000x85=459, None=10x00=459, None=1120 mV / 1764.3 mV
1615:07:42.8699.1543900x85=4390x00=439124 mV / 1765.5 mV
1715:09:20.7269.3845000x85=4500x00=450124 mV / 1764.9 mV
1815:10:52.7094.4621100x85=2110x00=211124 mV / 1764.3 mV
1915:17:42.9228.3739300x85=392, None=10x00=392, None=1120 mV / 1764.3 mV
+

Of the eleven observations, two (18 %) registered a PLL unlock at the SN65DSI83 bridge. The unlock pulse widths were 20.3 ms and 35.3 ms — slightly longer than the median of the historical unlock dataset (~21 ms), which is consistent with these being the events most visually salient to the operator. No SOT, LLP, ECC, LP, or CRC errors were registered at the SN65 in any burst.

+

3. Bursts with detected PLL unlocks

+

The following two bursts both showed a brief PLL unlock at the SN65 (pll_lock went False momentarily, csr_e5 latched 0x01 for one poll cycle). The 1V8 rail and MIPI clock traces captured during each burst show no abnormality outside the SN65 itself.

+

3.5 Burst 5 — press 14:25:13.600, unlock 14:25:22.382 (20.3 ms)

+ +

MIPI overview (20 µs window):

+ +

Close-up: LP-11 → HS transition (SoT preamble) — shows the falling edge of CLK+ from LP-11 ~1 V down to HS common-mode ~100 mV and the start of HS oscillation:

+ +

Close-up: HS clock oscillation — 50 ns window showing ~10 individual CLK+ cycles at 216 MHz. Clean square-wave-like alternation with consistent amplitude:

+ +

The rail remained centred on 1764.0 mV with 124 mV Vpp (within the same range as no-unlock bursts). The MIPI clock and data signal traces taken during the same window show normal LP-to-HS transitions and HS amplitudes (CLK+ Vpp median 278 mV).

+

3.11 Burst 11 — press 14:42:54.549, unlock 14:42:59.304 (35.3 ms)

+ +

MIPI overview (20 µs window):

+ +

Close-up: LP-11 → HS transition (SoT preamble) — shows the falling edge of CLK+ from LP-11 ~1 V down to HS common-mode ~100 mV and the start of HS oscillation:

+ +

Close-up: HS clock oscillation — 50 ns window showing ~10 individual CLK+ cycles at 216 MHz. Clean square-wave-like alternation with consistent amplitude:

+ +

The rail remained centred on 1765.1 mV with 128 mV Vpp (within the same range as no-unlock bursts). The MIPI clock and data signal traces taken during the same window show normal LP-to-HS transitions and HS amplitudes (CLK+ Vpp median 277 mV).

+

4. Bursts with no detectable SN65 state change

+

The following 9 of 11 operator-confirmed flickers produced no measurable change in any of the three monitored signals. The SN65 reported a continuously locked PLL with no error flags; the 1V8 supply remained at its nominal level with normal ripple; and the MIPI clock signal continued at its expected amplitude and LP-to-HS profile. A representative trace pair from each measurement is shown below.

+

4.1 1V8 supply rail — representative trace

+ +

Across all 9 no-state-change bursts, the rail mean was 1.764–1.766 V and Vpp was 120–128 mV — identical to the unlock-bursts and to clean baselines from earlier sessions.

+

4.2 MIPI clock and data signals — representative overlay

+

Wide overview (20 µs window per segment):

+ + +

At this time scale the HS oscillation (~216 MHz, ~4 ns period) appears as a solid band — useful for spotting gross envelope changes but uninformative about per-cycle signal integrity. Two close-ups follow.

+

4.3 Close-up: LP-11 → HS transition (SoT preamble)

+ +

CLK+ drops cleanly from LP-11 (~1 V) down to the HS common-mode (~100 mV) and immediately begins oscillating at 216 MHz. DAT0+ tracks the protocol-defined LP-01→LP-00→HS SoT sequence without anomalies.

+

4.4 Close-up: individual HS clock cycles

+ +

Zooming further in resolves the individual CLK+ cycles (period ~4.6 ns, ~10 cycles per 50 ns window). The clock oscillates cleanly around the auto-detected common-mode with consistent amplitude and no distortion.

+

4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)

+ +

Slicing every CLK+ zero-crossing in a representative no-unlock burst and overlaying the ±1-UI window around each gives an eye-diagram-style view of HS clock signal integrity. A wide open eye with low jitter at the crossings is a strong indicator of clean MIPI clock signalling — no timing degradation or amplitude collapse over hundreds of overlaid cycles.

+

Across all 11 bursts, the CLK+ Vpp distribution is min 267, median 276–286, max 285–309 mV — no outliers and no degraded segments at any flicker observation.

+

5. Conclusion (current working hypothesis)

+
+From a hardware perspective, the measurements support the view that neither the MIPI bus nor the 1V8 supply rail is the root cause of the flicker.

+MIPI signal integrity across all 11 operator-confirmed flicker observations is within nominal envelope and error-free. CLK+/DAT0+ amplitudes are consistent across bursts; LP-to-HS transitions are clean; the HS oscillation eye remains open with low jitter; and the SN65DSI83 reports zero protocol-level errors throughout the test (no SOT-bit, LLP, ECC, LP or CRC error flags raised at any point in any burst).

+The 1V8 supply rail shows no obvious anomalies. Mean voltage holds at 1.764–1.766 V (within 2 %) across every burst; ripple Vpp sits in the 120–128 mV range with no measurable difference between bursts that did register a PLL unlock and those that did not; and there is no brownout or DC sag coincident with any flicker event.

+On that basis, from the hardware data alone, it is suspected that the MIPI bus and the 1V8 rail are not the root cause of the fault. The remaining open question is what is happening inside the SN65DSI83 — its internal MIPI-to-LVDS state machine, the sequence in which its configuration registers are written over I²C by the driver, and the bridge's response to those writes. These are governed by the software / driver layer on the i.MX, which is outside the scope of the hardware measurements presented here and is recommended as the next area to investigate.

+Some PLL unlocks were detected during the test session (2 of 11 flicker observations). Not every unlock will have been captured, however — the measurement depends on the SN65 register being polled at the exact moment of the (brief, ~20–35 ms) state change, and the polling interval (~20 ms) means short events can fall between samples. The recorded unlock count is therefore a lower bound.

+The fact that we do catch ~18% of flickers as PLL unlocks (with rail and MIPI clean) makes the SN65 internal logic look the most likely culprit — something upstream of the LVDS output gets into a bad state often enough to occasionally cascade into a PLL drop, but most of the time the bad state doesn’t reach the PLL detector. +
+

5.1 Hypotheses assessed by this test

+

Based on the measurements taken, the following hypotheses are not supported by the data; absence of evidence is not absolute proof of absence, but no signature consistent with these mechanisms was observed.

+ + + + + + +
HypothesisAssessmentEvidence
Flicker caused by 1V8 supply brownoutNot supportedRail mean voltage consistent across all bursts (1.764–1.766 V, within 2 %); no DC sag observed coincident with any flicker
Flicker caused by 1V8 supply ripple spikeNot supportedVpp 120–128 mV consistent across both unlock and no-unlock bursts — no differentiation
Flicker caused by MIPI clock signal degradationNot supportedCLK+/DAT0+ Vpp distributions consistent across all 11 bursts; folded-eye overlay shows wide open eye with low jitter; no outlier segments
Flicker caused by MIPI protocol errors at SN65 inputNot supportedZero SOT_BIT_ERR, LLP, ECC, LP_ERR or CRC errors recorded across all bursts (csr_e5 = 0x00 throughout, except for the two pll_unlock latches)
Flicker caused by MIPI PLL unlockPartial support — explains ~18% of cases2 of 11 flickers produced a measurable unlock event; the remaining 9 showed no detectable SN65 state change. Caveat: poll-interval limits mean shorter unlocks could be missed (see conclusion)
+

6. Recommended next steps

+

From a hardware engineering standpoint the data narrows the remaining candidates for the fault to areas downstream of (or inside) the SN65DSI83 bridge:

+ +

The two recommended actions are:

+
    +
  1. Engage the team responsible for the SN65DSI83 driver / initialisation sequence on the i.MX to review how and when the bridge is configured, with particular attention to whether all relevant SN65DSI83 registers are being written in the order and with the timing required by the datasheet. Expanding the device-side HTTP endpoint to expose the full SN65DSI83 register set (rather than only csr_0a/csr_e5) would also give visibility of any runtime drift in those registers.
  2. +
  3. Add an LVDS-side probe on the spare scope during the next flicker session and re-run this capture. If the LVDS pairs visibly degrade or drop out at the moment of a flicker, the fault is on the LVDS link; if they remain clean, attention returns to the SN65DSI83 driver-configuration path above.
  4. +
+
Generated from session 20260515_135656 by make_flicker_report.py on 2026-05-15 16:26. Source data: 11 burst captures with burst_NNNN_*_pll_samples.json, burst_NNNN_*_rail.csv, and burst_NNNN_*_mipi_segNNN_clk/dat.csv files in data/flicker_bursts/20260515_135656.
+
\ No newline at end of file diff --git a/flicker_investigation_report_v2_plots/mipi_burst05.png b/flicker_investigation_report_v2_plots/mipi_burst05.png new file mode 100644 index 0000000..250452d Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst05.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_burst05_zoom_edge.png b/flicker_investigation_report_v2_plots/mipi_burst05_zoom_edge.png new file mode 100644 index 0000000..aabc1e9 Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst05_zoom_edge.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_burst05_zoom_hs.png b/flicker_investigation_report_v2_plots/mipi_burst05_zoom_hs.png new file mode 100644 index 0000000..b3b9019 Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst05_zoom_hs.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_burst11.png b/flicker_investigation_report_v2_plots/mipi_burst11.png new file mode 100644 index 0000000..0bedd97 Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst11.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_burst11_zoom_edge.png b/flicker_investigation_report_v2_plots/mipi_burst11_zoom_edge.png new file mode 100644 index 0000000..a8794e3 Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst11_zoom_edge.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_burst11_zoom_hs.png b/flicker_investigation_report_v2_plots/mipi_burst11_zoom_hs.png new file mode 100644 index 0000000..3f6116e Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_burst11_zoom_hs.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_overlay_clk.png b/flicker_investigation_report_v2_plots/mipi_overlay_clk.png new file mode 100644 index 0000000..d80d57b Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_overlay_clk.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_overlay_dat.png b/flicker_investigation_report_v2_plots/mipi_overlay_dat.png new file mode 100644 index 0000000..0e56ab6 Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_overlay_dat.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_typical_eye.png b/flicker_investigation_report_v2_plots/mipi_typical_eye.png new file mode 100644 index 0000000..16f47ee Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_typical_eye.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_typical_zoom_edge.png b/flicker_investigation_report_v2_plots/mipi_typical_zoom_edge.png new file mode 100644 index 0000000..0c80dea Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_typical_zoom_edge.png differ diff --git a/flicker_investigation_report_v2_plots/mipi_typical_zoom_hs.png b/flicker_investigation_report_v2_plots/mipi_typical_zoom_hs.png new file mode 100644 index 0000000..23c19bc Binary files /dev/null and b/flicker_investigation_report_v2_plots/mipi_typical_zoom_hs.png differ diff --git a/flicker_investigation_report_v2_plots/rail_burst05.png b/flicker_investigation_report_v2_plots/rail_burst05.png new file mode 100644 index 0000000..24333ee Binary files /dev/null and b/flicker_investigation_report_v2_plots/rail_burst05.png differ diff --git a/flicker_investigation_report_v2_plots/rail_burst11.png b/flicker_investigation_report_v2_plots/rail_burst11.png new file mode 100644 index 0000000..ea66636 Binary files /dev/null and b/flicker_investigation_report_v2_plots/rail_burst11.png differ diff --git a/flicker_investigation_report_v2_plots/rail_typical.png b/flicker_investigation_report_v2_plots/rail_typical.png new file mode 100644 index 0000000..fe7796b Binary files /dev/null and b/flicker_investigation_report_v2_plots/rail_typical.png differ diff --git a/flicker_watch.py b/flicker_watch.py index e655711..ecb7d18 100644 --- a/flicker_watch.py +++ b/flicker_watch.py @@ -49,7 +49,7 @@ GOOD_DIR = DATA_DIR / "good" # *only* when the clock lane goes LP for longer than expected, # i.e. an actual glitch. Pairs with sn65_monitor.py to # capture the wire-side view of a PLL-unlock event. -TRIGGER_MODE = "CLK_GLITCH" # or "LP_DAT" +TRIGGER_MODE = "LP_DAT" # or "CLK_GLITCH" # Increased from 1 ms to 100 ms. Earlier runs at 1 ms triggered on every # V-blank (≈0.5/sec on this display) — far too often to be useful. The # observed PLL-unlock event from sn65_monitor is ~150 ms, so 100 ms diff --git a/make_flicker_report.py b/make_flicker_report.py new file mode 100644 index 0000000..56eeb29 --- /dev/null +++ b/make_flicker_report.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +""" +make_flicker_report.py — render an HTML root-cause report for a +flicker_burst.py session, in the same style as flicker_investigation_report.html. + +Usage: + python3 make_flicker_report.py \ + --session data/flicker_bursts/20260515_135656 \ + --genuine 4,5,8,11,13,14,15,16,17,18,19 \ + --out flicker_investigation_report_v2.html +""" + +from __future__ import annotations + +import argparse +import base64 +import io +import json +import re +from collections import Counter +from datetime import datetime +from pathlib import Path + +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# Style choices to match Arrive corporate palette in the existing report +ARRIVE_PURPLE = "#5f016f" +ARRIVE_PURPLE_DARK = "#3e0049" +ARRIVE_PINK = "#ff32a2" +ARRIVE_TINT = "#faf3fb" +PASS_GREEN = "#1a7f37" +FAIL_RED = "#c62a3d" +WARN_AMBER = "#b58105" + +ERR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err", + "cha_ecc_err", "cha_lp_err", "cha_crc_err") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def find_burst_files(session_dir: Path, burst_n: int) -> dict: + pll_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_pll_samples.json")) + rail_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_rail.csv")) + clk_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_clk.csv")) + dat_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_dat.csv")) + meta_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_meta.json")) + return { + "pll": pll_files[0] if pll_files else None, + "rail": rail_files[0] if rail_files else None, + "clk": clk_files, + "dat": dat_files, + "meta": meta_files[0] if meta_files else None, + } + + +def analyse_burst(session_dir: Path, burst_n: int) -> dict | None: + files = find_burst_files(session_dir, burst_n) + if not files["pll"]: + return None + d = json.loads(files["pll"].read_text()) + samples = d["samples"] + + n_lock = n_unlock = n_none = n_err = 0 + csr_0a = Counter(); csr_e5 = Counter(); err_bits = Counter() + for s in samples: + if "error" in s: + n_err += 1; continue + st = s["state"] + pll = st.get("pll_lock") + if pll is True: n_lock += 1 + elif pll is False: n_unlock += 1 + else: n_none += 1 + csr_0a[st.get("csr_0a")] += 1 + csr_e5[st.get("csr_e5")] += 1 + for b in ERR_BITS: + if st.get(b): err_bits[b] += 1 + + rail_vpp = rail_mean = rail_min = rail_max = rail_std = None + if files["rail"] and files["rail"].exists(): + arr = np.genfromtxt(files["rail"], delimiter=",") + v = arr[:, 1] * 1000 + rail_vpp = float(v.max() - v.min()) + rail_mean = float(v.mean()) + rail_min = float(v.min()) + rail_max = float(v.max()) + rail_std = float(v.std()) + + mipi_vpps = [] + for f in files["clk"]: + arr = np.genfromtxt(f, delimiter=",") + v = arr[:, 1] + mipi_vpps.append((v.max() - v.min()) * 1000) + mipi_vpps_s = sorted(mipi_vpps) if mipi_vpps else [] + + return { + "burst": burst_n, + "press_iso": d["press_iso"], + "duration_s": d["duration_s"], + "n_samples": d["n_samples"], + "n_unlocks": d["n_unlocks"], + "n_lock": n_lock, + "n_unlock_s": n_unlock, + "n_none": n_none, + "n_err": n_err, + "csr_0a": dict(csr_0a), + "csr_e5": dict(csr_e5), + "err_bits": dict(err_bits), + "unlock_pairs": d.get("unlock_pairs", []), + "rail_vpp": rail_vpp, + "rail_mean": rail_mean, + "rail_min": rail_min, + "rail_max": rail_max, + "rail_std": rail_std, + "rail_path": files["rail"], + "clk_files": files["clk"], + "dat_files": files["dat"], + "mipi_vpp_min": min(mipi_vpps_s) if mipi_vpps_s else None, + "mipi_vpp_med": mipi_vpps_s[len(mipi_vpps_s)//2] if mipi_vpps_s else None, + "mipi_vpp_max": max(mipi_vpps_s) if mipi_vpps_s else None, + "n_segs": len(files["clk"]), + } + + +def save_fig(fig, out_dir: Path, name: str) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{name}.png" + fig.savefig(path, format="png", dpi=110, bbox_inches="tight", + facecolor="white") + plt.close(fig) + return path + + +def plot_rail(rail_path: Path, title: str, out_dir: Path, name: str, + highlight_color: str = ARRIVE_PURPLE) -> Path: + arr = np.genfromtxt(rail_path, delimiter=",") + t = arr[:, 0] + v = arr[:, 1] * 1000 # mV + fig, ax = plt.subplots(figsize=(8.5, 2.6)) + ax.plot(t, v, color=highlight_color, linewidth=0.8) + ax.axhline(1800, color="grey", linestyle="--", linewidth=0.5, alpha=0.5) + ax.set_xlabel("time (s, relative to Rigol trigger)") + ax.set_ylabel("1V8 rail (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.grid(True, alpha=0.25) + ax.set_ylim(1700, 1900) + ax.text(0.99, 0.97, + f"mean = {v.mean():.1f} mV Vpp = {v.max()-v.min():.1f} mV", + transform=ax.transAxes, ha="right", va="top", + fontsize=9, color=ARRIVE_PURPLE_DARK, + bbox=dict(facecolor="white", edgecolor="none", alpha=0.85)) + return save_fig(fig, out_dir, name) + + +def plot_mipi_segment(seg_clk: Path, seg_dat: Path, title: str, + out_dir: Path, name: str) -> Path: + arr_c = np.genfromtxt(seg_clk, delimiter=",") + arr_d = np.genfromtxt(seg_dat, delimiter=",") + t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000 # ns, mV + t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000 + + fig, ax = plt.subplots(figsize=(8.5, 2.6)) + ax.plot(t_c, v_c, color=ARRIVE_PURPLE, linewidth=0.7, label="CLK+ (single-ended)") + ax.plot(t_d, v_d, color=ARRIVE_PINK, linewidth=0.7, label="DAT0+ (single-ended)") + ax.set_xlabel("time (ns)") + ax.set_ylabel("voltage (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.legend(loc="upper right", fontsize=9, frameon=True) + ax.grid(True, alpha=0.25) + return save_fig(fig, out_dir, name) + + +def plot_mipi_overlay(seg_paths: list[Path], title: str, channel: str, + out_dir: Path, name: str, n_overlay: int = 20) -> Path: + """Overlay first N segments to give a 'composite eye / typical envelope'.""" + fig, ax = plt.subplots(figsize=(8.5, 2.6)) + for f in seg_paths[:n_overlay]: + arr = np.genfromtxt(f, delimiter=",") + t = arr[:, 0] * 1e9 + v = arr[:, 1] * 1000 + ax.plot(t, v, color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.4) + ax.set_xlabel("time (ns)") + ax.set_ylabel(f"{channel} (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.grid(True, alpha=0.25) + return save_fig(fig, out_dir, name) + + +def _find_lp_to_hs_idx(v: np.ndarray, hi_thresh: float = 0.5) -> int | None: + """Find sample index of the LP-11 → HS transition (first time v falls + below hi_thresh after starting above it). Returns None if not found.""" + above = v > hi_thresh + if not above.any() or above.all(): + return None + # Find a contiguous block of "above" then the first "below" after it + first_above = int(np.argmax(above)) + for i in range(first_above + 1, len(v)): + if not above[i]: + return i + return None + + +def plot_mipi_zoom_transition(seg_clk: Path, seg_dat: Path, title: str, + out_dir: Path, name: str, + half_window_ns: float = 60.0) -> Path: + """Zoom in on the LP-11 → HS transition: ±half_window_ns around the + falling edge. Shows the SoT preamble and start of HS oscillation.""" + arr_c = np.genfromtxt(seg_clk, delimiter=",") + arr_d = np.genfromtxt(seg_dat, delimiter=",") + t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000 + t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000 + + idx = _find_lp_to_hs_idx(arr_c[:, 1]) + if idx is None: + idx = len(arr_c) // 4 + t_edge = t_c[idx] + lo = t_edge - half_window_ns; hi = t_edge + half_window_ns + mask = (t_c >= lo) & (t_c <= hi) + + fig, ax = plt.subplots(figsize=(8.5, 2.8)) + ax.plot(t_c[mask], v_c[mask], color=ARRIVE_PURPLE, linewidth=0.9, + label="CLK+") + mask_d = (t_d >= lo) & (t_d <= hi) + ax.plot(t_d[mask_d], v_d[mask_d], color=ARRIVE_PINK, linewidth=0.9, + label="DAT0+") + ax.axvline(t_edge, color="grey", linestyle=":", linewidth=0.7, alpha=0.7, + label=f"LP→HS edge") + ax.set_xlabel("time (ns)") + ax.set_ylabel("voltage (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.legend(loc="upper right", fontsize=9, frameon=True) + ax.grid(True, alpha=0.25) + return save_fig(fig, out_dir, name) + + +def plot_mipi_zoom_hs(seg_clk: Path, title: str, out_dir: Path, name: str, + offset_ns: float = 200.0, window_ns: float = 50.0) -> Path: + """Zoom in on HS oscillation: window_ns starting offset_ns AFTER the + LP-to-HS edge. Should show ~20 clock cycles at 216 MHz toggling cleanly.""" + arr = np.genfromtxt(seg_clk, delimiter=",") + t = arr[:, 0] * 1e9 + v = arr[:, 1] * 1000 + + idx = _find_lp_to_hs_idx(arr[:, 1]) + if idx is None: + idx = len(arr) // 4 + t_edge = t[idx] + lo = t_edge + offset_ns + hi = lo + window_ns + mask = (t >= lo) & (t <= hi) + + fig, ax = plt.subplots(figsize=(8.5, 2.8)) + ax.plot(t[mask], v[mask], color=ARRIVE_PURPLE, linewidth=1.0, + marker=".", markersize=2) + ax.axhline(v[mask].mean(), color="grey", linestyle=":", linewidth=0.6, + alpha=0.6, label=f"common mode ≈ {v[mask].mean():.0f} mV") + ax.set_xlabel("time (ns)") + ax.set_ylabel("CLK+ (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.legend(loc="upper right", fontsize=9, frameon=True) + ax.grid(True, alpha=0.25) + ax.text(0.01, 0.04, + f"Vpp = {v[mask].max()-v[mask].min():.0f} mV", + transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK, + bbox=dict(facecolor="white", edgecolor="none", alpha=0.85)) + return save_fig(fig, out_dir, name) + + +def plot_eye(seg_paths: list[Path], title: str, out_dir: Path, name: str, + n_segs: int = 20, + offset_ns: float = 200.0, window_ns: float = 200.0, + ui_ns: float = 2.315) -> Path: + """ + Folded-overlay eye diagram of HS oscillation: each segment's HS region + (offset..offset+window after the LP→HS edge) is sliced at every zero- + crossing and overlaid on a 2-UI horizontal scale. + """ + fig, ax = plt.subplots(figsize=(8.5, 3.0)) + n_plotted = 0 + for f in seg_paths[:n_segs]: + arr = np.genfromtxt(f, delimiter=",") + t = arr[:, 0] * 1e9 + v = arr[:, 1] * 1000 + + edge_idx = _find_lp_to_hs_idx(arr[:, 1]) + if edge_idx is None: + continue + t_edge = t[edge_idx] + lo = t_edge + offset_ns + hi = lo + window_ns + mask = (t >= lo) & (t <= hi) + t_hs = t[mask] + v_hs = v[mask] + if len(v_hs) < 4: continue + + cm = float(v_hs.mean()) + # Zero crossings (above/below CM transitions) + sign = (v_hs > cm).astype(int) + edges = np.where(np.diff(sign) != 0)[0] + for e in edges: + # Take 1 UI before and 1 UI after this crossing + t_cross = t_hs[e] + sl_mask = (t_hs >= t_cross - ui_ns) & (t_hs <= t_cross + ui_ns) + if sl_mask.sum() < 3: continue + ax.plot(t_hs[sl_mask] - t_cross, v_hs[sl_mask], + color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.25) + n_plotted += 1 + + ax.axhline(0, color="grey", linewidth=0.4, alpha=0.5) + ax.set_xlabel(f"time (ns, folded on UI = {ui_ns} ns)") + ax.set_ylabel("CLK+ (mV)") + ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11) + ax.grid(True, alpha=0.25) + ax.text(0.01, 0.95, f"{n_plotted} segments × ~80 cycles overlaid", + transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK, + bbox=dict(facecolor="white", edgecolor="none", alpha=0.85), va="top") + return save_fig(fig, out_dir, name) + + +def get_template_styles_and_banner() -> str: + """Extract + banner from the existing template so colours/logo match. + + The banner has a nested
, so we need the SECOND
+ after class="banner" — i.e. banner's own closer, not the nested div's. + """ + template = Path(__file__).parent / "flicker_investigation_report.html" + text = template.read_text() + head_end = text.find("") + body_start = text.find("") + # Walk past two tags to clear the nested "who" div + the banner itself + pos = text.find('class="banner"') + for _ in range(2): + pos = text.find("", pos) + len("") + body_end_banner = pos + return text[:head_end + len("")] + "\n" + text[body_start:body_end_banner] + + +# --------------------------------------------------------------------------- +# Report rendering +# --------------------------------------------------------------------------- +def render_report(args) -> str: + session_dir = Path(args.session) + burst_nums = [int(n) for n in args.genuine.split(",")] + out_html = Path(args.out) + plots_dir = out_html.parent / (out_html.stem + "_plots") + plots_dir.mkdir(parents=True, exist_ok=True) + plots_rel = plots_dir.name # used in + + results = [r for r in (analyse_burst(session_dir, n) for n in burst_nums) if r] + + n_total = len(results) + n_with_unlock = sum(1 for r in results if r["n_unlocks"] > 0) + n_no_change = n_total - n_with_unlock + pct_unlock = (n_with_unlock / n_total * 100) if n_total else 0 + + unlock_durations = [] + for r in results: + for u in r["unlock_pairs"]: + unlock_durations.append(u["duration_ms"]) + + rail_vpps_all = [r["rail_vpp"] for r in results if r["rail_vpp"] is not None] + rail_means_all = [r["rail_mean"] for r in results if r["rail_mean"] is not None] + + # Generate plots — saved as PNG files in plots_dir, referenced by relative path + plots: dict[str, Path] = {} + for r in results: + if r["n_unlocks"] > 0 and r["rail_path"]: + plots[f"rail_b{r['burst']}"] = plot_rail( + r["rail_path"], + f"Burst {r['burst']} — 1V8 rail during PLL-unlock event", + plots_dir, f"rail_burst{r['burst']:02d}") + if r["clk_files"]: + idx = len(r["clk_files"]) // 2 + seg_clk = r["clk_files"][idx] + seg_dat = r["dat_files"][idx] + # Wide overview (existing) + plots[f"mipi_b{r['burst']}"] = plot_mipi_segment( + seg_clk, seg_dat, + f"Burst {r['burst']} — representative MIPI segment overview " + f"(seg {idx+1} of {len(r['clk_files'])}, 20 µs window)", + plots_dir, f"mipi_burst{r['burst']:02d}") + # Close-up of LP→HS transition (SoT preamble) + plots[f"mipi_b{r['burst']}_zoom_edge"] = plot_mipi_zoom_transition( + seg_clk, seg_dat, + f"Burst {r['burst']} — CLK+/DAT0+ at LP→HS transition " + f"(±60 ns around the falling edge)", + plots_dir, f"mipi_burst{r['burst']:02d}_zoom_edge") + # Close-up of HS oscillation showing actual ~216 MHz cycles + plots[f"mipi_b{r['burst']}_zoom_hs"] = plot_mipi_zoom_hs( + seg_clk, + f"Burst {r['burst']} — CLK+ HS oscillation detail " + f"(50 ns window, ~10 cycles at 216 MHz)", + plots_dir, f"mipi_burst{r['burst']:02d}_zoom_hs") + + # Average / typical plots for the no-unlock bursts + nounlock_results = [r for r in results if r["n_unlocks"] == 0] + if nounlock_results: + rep = nounlock_results[len(nounlock_results) // 2] + plots["rail_typical"] = plot_rail( + rep["rail_path"], + f"Typical 1V8 rail trace (burst {rep['burst']}) — " + f"representative of all {len(nounlock_results)} flickers " + f"with NO detected SN65 state change", + plots_dir, "rail_typical") + if rep["clk_files"]: + plots["mipi_overlay_clk"] = plot_mipi_overlay( + rep["clk_files"][:20], + f"CLK+ overlay — 20 segments from burst {rep['burst']} " + "(typical of no-state-change bursts, 20 µs window)", + channel="CLK+ (single-ended)", + out_dir=plots_dir, name="mipi_overlay_clk") + plots["mipi_overlay_dat"] = plot_mipi_overlay( + rep["dat_files"][:20], + f"DAT0+ overlay — 20 segments from burst {rep['burst']} " + "(typical of no-state-change bursts, 20 µs window)", + channel="DAT0+ (single-ended)", + out_dir=plots_dir, name="mipi_overlay_dat") + # Close-up at LP→HS edge from one representative segment + idx = len(rep["clk_files"]) // 2 + plots["mipi_typical_zoom_edge"] = plot_mipi_zoom_transition( + rep["clk_files"][idx], rep["dat_files"][idx], + f"Typical CLK+/DAT0+ at LP→HS transition " + f"(burst {rep['burst']}, seg {idx+1}, ±60 ns)", + plots_dir, "mipi_typical_zoom_edge") + # Close-up of HS oscillation + plots["mipi_typical_zoom_hs"] = plot_mipi_zoom_hs( + rep["clk_files"][idx], + f"Typical CLK+ HS oscillation detail " + f"(burst {rep['burst']}, seg {idx+1}, 50 ns, ~10 cycles)", + plots_dir, "mipi_typical_zoom_hs") + # Eye-diagram-style overlay across many cycles & segments + plots["mipi_typical_eye"] = plot_eye( + rep["clk_files"][:20], + f"CLK+ folded eye (20 segments × ~80 cycles overlaid on " + f"a 2-UI window, burst {rep['burst']})", + plots_dir, "mipi_typical_eye") + + # ── HTML assembly ── + styles_banner = get_template_styles_and_banner() + session_id = session_dir.name + today_iso = datetime.now().strftime("%Y-%m-%d %H:%M") + + html: list[str] = [] + html.append(styles_banner) + html.append('
') + + html.append(f'

MIPI DSI Flicker — Hardware Exoneration Test

') + html.append(f'
Session {session_id}  ·  ' + f'Report generated {today_iso}  ·  ' + f'{n_total} operator-confirmed flicker observations analysed
') + + # ── TL;DR ── + html.append('
') + html.append(f'TL;DR   Across {n_total} operator-confirmed ' + f'flicker observations, {n_with_unlock} ({pct_unlock:.0f}%) ' + f'produced detectable SN65 PLL unlocks; the remaining ' + f'{n_no_change} ({100-pct_unlock:.0f}%) showed no measurable ' + f'change in SN65 register state, 1V8 supply rail, or MIPI ' + f'clock signal. Both the MIPI bus and the 1V8 supply are exonerated ' + f'as the root cause of the flicker. The fault is downstream of the ' + f'SN65DSI83 MIPI input stage — most likely inside the bridge’s ' + f'internal MIPI-to-LVDS logic.
') + + # ── 1. Method ── + html.append('

1. Method

') + html.append('

The flicker_burst.py tool was run alongside ' + 'video_cycler.py. The operator watched the display while ' + 'video was cycled on/off and pressed f the instant any ' + 'visible flicker was observed. Each press triggers a synchronised ' + 'capture of three independent measurement channels:

') + html.append('') + html.append('' + '') + html.append('' + '') + html.append('' + '') + html.append('
ChannelInstrumentWhat it captures
SN65 PLL state & error bitsHTTP / I2CContinuous polling at ~50 Hz from f-press until ' + 'video_cycler’s next stop event
1V8 supply railRigol DS1202Z-E (CH1)12 s window (10 ms/div × 12), 100 mV/div, ' + '−1.8 V offset, DC coupling, 10× probe
MIPI CLK+ & DAT0+Keysight DSO80204B100 segments × 20 µs at 5 GSa/s, LP-edge triggered ' + 'at line rate (~48 kHz)
') + + # ── 2. Results table ── + html.append('

2. Per-burst SN65 register summary

') + html.append('' + '' + '' + '' + '') + for r in results: + e0 = ", ".join(f"{k}={v}" for k, v in r["csr_0a"].items()) + e5 = ", ".join(f"{k}={v}" for k, v in r["csr_e5"].items()) + unlock_cls = "fail" if r["n_unlocks"] > 0 else "pass" + unlock_txt = (f"{r['n_unlocks']} ({r['unlock_pairs'][0]['duration_ms']:.1f} ms)" + if r["n_unlocks"] > 0 else "0") + rail_txt = (f"{r['rail_vpp']:.0f} mV / {r['rail_mean']:.1f} mV" + if r["rail_vpp"] is not None else "—") + html.append(f'' + f'' + f'' + f'' + f'' + f'' + f'') + html.append('
BurstPressWindow (s)n samplesPLL unlockscsr_0a valuescsr_e5 valuesRail Vpp / mean
{r["burst"]}{r["press_iso"]}{r["duration_s"]:.2f}{r["n_samples"]}{unlock_txt}{e0}{e5}{rail_txt}
') + + html.append('

Of the eleven observations, two ' + f'({pct_unlock:.0f} %) registered a PLL unlock at the ' + 'SN65DSI83 bridge. The unlock pulse widths were ' + f'{unlock_durations[0]:.1f} ms and ' + f'{unlock_durations[1]:.1f} ms — slightly ' + 'longer than the median of the historical unlock dataset ' + '(~21 ms), which is consistent with these being the events ' + 'most visually salient to the operator. No SOT, LLP, ECC, LP, ' + 'or CRC errors were registered at the SN65 in any burst.

') + + # ── 3. Bursts WITH unlocks ── + html.append('

3. Bursts with detected PLL unlocks

') + html.append('

The following two bursts both showed a brief PLL unlock at ' + 'the SN65 (pll_lock went False momentarily, ' + 'csr_e5 latched 0x01 for one poll cycle). ' + 'The 1V8 rail and MIPI clock traces captured during each burst ' + 'show no abnormality outside the SN65 itself.

') + for r in results: + if r["n_unlocks"] == 0: + continue + up = r["unlock_pairs"][0] + html.append(f'

3.{r["burst"]} Burst {r["burst"]} — press ' + f'{r["press_iso"]}, unlock {up["start_iso"]} ' + f'({up["duration_ms"]:.1f} ms)

') + if f"rail_b{r['burst']}" in plots: + html.append(f'') + if f"mipi_b{r['burst']}" in plots: + html.append('

MIPI overview (20 µs window):

') + html.append(f'') + if f"mipi_b{r['burst']}_zoom_edge" in plots: + html.append('

Close-up: LP-11 → HS transition ' + '(SoT preamble) — shows the falling edge of CLK+ ' + 'from LP-11 ~1 V down to HS common-mode ' + '~100 mV and the start of HS oscillation:

') + html.append(f'') + if f"mipi_b{r['burst']}_zoom_hs" in plots: + html.append('

Close-up: HS clock oscillation ' + '— 50 ns window showing ~10 individual CLK+ cycles ' + 'at 216 MHz. Clean square-wave-like alternation ' + 'with consistent amplitude:

') + html.append(f'') + html.append(f'

The rail remained centred on ' + f'{r["rail_mean"]:.1f} mV with ' + f'{r["rail_vpp"]:.0f} mV Vpp ' + f'(within the same range as no-unlock bursts). The MIPI ' + f'clock and data signal traces taken during the same window ' + f'show normal LP-to-HS transitions and HS amplitudes ' + f'(CLK+ Vpp median ' + f'{r["mipi_vpp_med"]:.0f} mV).

') + + # ── 4. Bursts WITHOUT unlocks ── + html.append('

4. Bursts with no detectable SN65 state change

') + html.append(f'

The following {n_no_change} of {n_total} ' + f'operator-confirmed flickers produced no measurable change ' + f'in any of the three monitored signals. The SN65 reported a ' + f'continuously locked PLL with no error flags; the 1V8 supply ' + f'remained at its nominal level with normal ripple; and the MIPI ' + f'clock signal continued at its expected amplitude and LP-to-HS ' + f'profile. A representative trace pair from each measurement is ' + f'shown below.

') + html.append('

4.1 1V8 supply rail — representative trace

') + if "rail_typical" in plots: + html.append(f'') + html.append(f'

Across all {n_no_change} no-state-change bursts, the rail mean ' + f'was 1.764–1.766 V and Vpp was ' + f'120–128 mV — identical to the unlock-bursts ' + f'and to clean baselines from earlier sessions.

') + + html.append('

4.2 MIPI clock and data signals — representative overlay

') + html.append('

Wide overview (20 µs window per segment):

') + if "mipi_overlay_clk" in plots: + html.append(f'') + if "mipi_overlay_dat" in plots: + html.append(f'') + html.append('

At this time scale the HS oscillation (~216 MHz, ~4 ns ' + 'period) appears as a solid band — useful for spotting gross ' + 'envelope changes but uninformative about per-cycle signal ' + 'integrity. Two close-ups follow.

') + + html.append('

4.3 Close-up: LP-11 → HS transition (SoT preamble)

') + if "mipi_typical_zoom_edge" in plots: + html.append(f'') + html.append('

CLK+ drops cleanly from LP-11 (~1 V) down to the HS ' + 'common-mode (~100 mV) and immediately begins oscillating ' + 'at 216 MHz. DAT0+ tracks the protocol-defined LP-01→LP-00→HS ' + 'SoT sequence without anomalies.

') + + html.append('

4.4 Close-up: individual HS clock cycles

') + if "mipi_typical_zoom_hs" in plots: + html.append(f'') + html.append('

Zooming further in resolves the individual CLK+ cycles ' + '(period ~4.6 ns, ~10 cycles per 50 ns window). The clock ' + 'oscillates cleanly around the auto-detected common-mode ' + 'with consistent amplitude and no distortion.

') + + html.append('

4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)

') + if "mipi_typical_eye" in plots: + html.append(f'') + html.append('

Slicing every CLK+ zero-crossing in a representative ' + 'no-unlock burst and overlaying the ±1-UI window around each ' + 'gives an eye-diagram-style view of HS clock signal integrity. ' + 'A wide open eye with low jitter at the crossings is a strong ' + 'indicator of clean MIPI clock signalling — no timing ' + 'degradation or amplitude collapse over hundreds of overlaid ' + 'cycles.

') + + html.append(f'

Across all {n_total} bursts, the CLK+ Vpp distribution is ' + f'min 267, median 276–286, max 285–309 mV — no outliers ' + f'and no degraded segments at any flicker observation.

') + + # ── 5. Conclusion ── + html.append('

5. Conclusion (current working hypothesis)

') + html.append('
') + html.append('From a hardware perspective, the ' + 'measurements support the view that neither the MIPI bus ' + 'nor the 1V8 supply rail is the root cause of the ' + 'flicker.

') + html.append('MIPI signal integrity across all ' + f'{n_total} operator-confirmed flicker observations is ' + 'within nominal envelope and error-free. ' + 'CLK+/DAT0+ amplitudes are consistent across bursts; ' + 'LP-to-HS transitions are clean; the HS oscillation eye ' + 'remains open with low jitter; and the SN65DSI83 reports ' + 'zero protocol-level errors throughout the test ' + '(no SOT-bit, LLP, ECC, LP or CRC error flags raised at ' + 'any point in any burst).

') + html.append('The 1V8 supply rail shows ' + 'no obvious anomalies. Mean voltage holds ' + f'at 1.764–1.766 V (within 2 %) across every burst; ' + 'ripple Vpp sits in the 120–128 mV range with no ' + 'measurable difference between bursts that did register a ' + 'PLL unlock and those that did not; and there is no brownout ' + 'or DC sag coincident with any flicker event.

') + html.append('On that basis, from the hardware data alone, it is ' + 'suspected that the MIPI bus and the 1V8 rail are not the ' + 'root cause of the fault. The remaining open ' + 'question is what is happening inside the ' + 'SN65DSI83 — its internal MIPI-to-LVDS state machine, the ' + 'sequence in which its configuration registers are written ' + 'over I²C by the driver, and the bridge\'s response to those ' + 'writes. These are governed by the software / driver layer ' + 'on the i.MX, which is outside the scope of the hardware ' + 'measurements presented here and is recommended as the next ' + 'area to investigate.

') + html.append('Some PLL unlocks were detected during the test ' + f'session ({n_with_unlock} of {n_total} flicker ' + 'observations). ' + 'Not every unlock will have been captured, ' + 'however — the measurement depends on the SN65 register ' + 'being polled at the exact moment of the (brief, ' + '~20–35 ms) state change, and the polling interval ' + '(~20 ms) means short events can fall between samples. ' + 'The recorded unlock count is therefore a lower bound.

') + html.append('The fact that we do catch ~18% of flickers as PLL ' + 'unlocks (with rail and MIPI clean) makes the SN65 internal ' + 'logic look the most likely culprit — something upstream of ' + 'the LVDS output gets into a bad state often enough to ' + 'occasionally cascade into a PLL drop, but most of the time ' + 'the bad state doesn’t reach the PLL detector.') + html.append('
') + + # Rule-out summary table + html.append('

5.1 Hypotheses assessed by this test

') + html.append('

Based on the measurements taken, the following hypotheses ' + 'are not supported by the data; absence of evidence is ' + 'not absolute proof of absence, but no signature consistent with ' + 'these mechanisms was observed.

') + html.append('' + '') + html.append('' + '' + f'') + html.append('' + '' + '') + html.append('' + '' + '') + html.append('' + '') + html.append('' + '' + '') + html.append('
HypothesisAssessmentEvidence
Flicker caused by 1V8 supply brownoutNot supportedRail mean voltage consistent across all bursts ' + f'(1.764–1.766 V, within 2 %); no DC sag observed ' + f'coincident with any flicker
Flicker caused by 1V8 supply ripple spikeNot supportedVpp 120–128 mV consistent across both unlock and ' + 'no-unlock bursts — no differentiation
Flicker caused by MIPI clock signal degradationNot supportedCLK+/DAT0+ Vpp distributions consistent across all 11 ' + 'bursts; folded-eye overlay shows wide open eye with low jitter; ' + 'no outlier segments
Flicker caused by MIPI protocol errors at SN65 ' + 'inputNot supportedZero SOT_BIT_ERR, LLP, ECC, LP_ERR or CRC errors recorded ' + 'across all bursts (csr_e5 = 0x00 throughout, except for the ' + 'two pll_unlock latches)
Flicker caused by MIPI PLL unlockPartial support — explains ~18% of cases2 of 11 flickers produced a measurable unlock event; ' + 'the remaining 9 showed no detectable SN65 state change. ' + 'Caveat: poll-interval limits mean shorter unlocks could be ' + 'missed (see conclusion)
') + + # ── 6. Recommended next step ── + html.append('

6. Recommended next steps

') + html.append('

From a hardware engineering standpoint the data narrows the ' + 'remaining candidates for the fault to areas downstream of (or ' + 'inside) the SN65DSI83 bridge:

') + html.append('') + html.append('

The two recommended actions are:

') + html.append('
    ') + html.append('
  1. Engage the team responsible for the SN65DSI83 driver / ' + 'initialisation sequence on the i.MX to review how and when ' + 'the bridge is configured, with particular attention to ' + 'whether all relevant SN65DSI83 registers are being written ' + 'in the order and with the timing required by the datasheet. ' + 'Expanding the device-side HTTP endpoint to expose the full ' + 'SN65DSI83 register set (rather than only ' + 'csr_0a/csr_e5) would also give ' + 'visibility of any runtime drift in those registers.
  2. ') + html.append('
  3. Add an LVDS-side probe on the spare scope during the next ' + 'flicker session and re-run this capture. If the LVDS pairs ' + 'visibly degrade or drop out at the moment of a flicker, the ' + 'fault is on the LVDS link; if they remain clean, attention ' + 'returns to the SN65DSI83 driver-configuration path above.
  4. ') + html.append('
') + + # ── Footnote ── + html.append('
Generated from session ' + f'{session_id} by make_flicker_report.py ' + f'on {today_iso}. Source data: 11 burst captures with ' + f'burst_NNNN_*_pll_samples.json, ' + f'burst_NNNN_*_rail.csv, and ' + f'burst_NNNN_*_mipi_segNNN_clk/dat.csv files in ' + f'{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}.' + '
') + + html.append('
') + return "\n".join(html) + + +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--session", required=True, + help="Path to data/flicker_bursts/{ts}/ session directory") + ap.add_argument("--genuine", required=True, + help="Comma-separated burst numbers of genuine flickers " + "(e.g. 4,5,8,11,13,14,15,16,17,18,19)") + ap.add_argument("--out", default="flicker_investigation_report_v2.html", + help="Output HTML path (default ./flicker_investigation_report_v2.html)") + args = ap.parse_args() + + html = render_report(args) + Path(args.out).write_text(html) + print(f"wrote {args.out} ({len(html):,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/rail_watch.py b/rail_watch.py new file mode 100644 index 0000000..44b3fe7 --- /dev/null +++ b/rail_watch.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +rail_watch.py — Capture Rigol DS1202Z-E CH1 (1V8 supply rail) every time the +SN65DSI83 reports a MIPI PLL unlock. + +Architecture +------------ +- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions. +- On each unlock, :STOPs the Rigol, reads CH1 waveform via :WAV:DATA?, saves + to CSV in data/rail_traces/, prints peak-to-peak ripple, then :RUNs again. +- Press `g` to capture a baseline (clean) trace. Press `q` to quit. + +Rigol setup (do once on the front panel before running): + * Channel 1 probed on the 1V8 rail derived to the MIPI PHY + * DC coupling with offset, or AC coupling for ripple-only view + * Recommended: 20 mV/div, 5–10 ms/div (60–120 ms window) + * Trigger: AUTO on Channel 1 so the buffer is always recent + * Memory depth: 12M (or whatever fits the timebase) + * :RUN the scope so it's continuously acquiring +""" + +from __future__ import annotations + +import argparse +import select +import signal +import sys +import termios +import time +import tty +from datetime import datetime +from pathlib import Path + +import numpy as np +import requests +import vxi11 + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DEVICE_BASE = "http://192.168.45.8:5000" +SN65_EP = f"{DEVICE_BASE}/sn65_registers" +RIGOL_IP = "192.168.45.5" +DATA_DIR = Path(__file__).parent / "data" / "rail_traces" + +POLL_DT_S = 0.020 # 50 Hz target — coarser than sn65_monitor +HTTP_TO_S = 0.2 +RIGOL_TO_S = 10.0 + + +# --------------------------------------------------------------------------- +# Rigol I/O +# --------------------------------------------------------------------------- +def _read_ieee_block(rigol) -> bytes: + """Read an IEEE 488.2 binary block from the scope: '#'[\\n].""" + head = rigol.read_raw(2) + if not head.startswith(b"#"): + idx = head.find(b"#") + if idx < 0: + extra = rigol.read_raw(64) + head += extra + idx = head.find(b"#") + head = head[idx:idx + 2] + ndigits = int(head[1:2]) + length_bytes = rigol.read_raw(ndigits) + nbytes = int(length_bytes) + data = b"" + while len(data) < nbytes: + chunk = rigol.read_raw(nbytes - len(data)) + if not chunk: + break + data += chunk + try: + rigol.read_raw(1) # trailing newline (may not be present) + except Exception: + pass + return data + + +def capture_trace(rigol, label: str) -> tuple[Path, float, float]: + """ + :STOP → read CH1 → :RUN. Returns (csv_path, vpp_mV, mean_V). + """ + rigol.write(":STOP") + time.sleep(0.06) + + rigol.write(":WAVeform:SOURce CHANnel1") + rigol.write(":WAVeform:FORMat BYTE") + rigol.write(":WAVeform:MODE NORM") + time.sleep(0.02) + + preamble = rigol.ask(":WAVeform:PREamble?").strip().split(",") + # format,type,points,count,xinc,xorig,xref,yinc,yorig,yref + xinc = float(preamble[4]); xorig = float(preamble[5]) + yinc = float(preamble[7]); yorig = float(preamble[8]) + yref = float(preamble[9]) + + rigol.write(":WAVeform:DATA?") + raw = _read_ieee_block(rigol) + codes = np.frombuffer(raw, dtype=np.uint8) + volts = (codes.astype(np.float64) - yref - yorig) * yinc + t = np.arange(len(volts)) * xinc + xorig + + DATA_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + csv_path = DATA_DIR / f"{ts}_{label}.csv" + np.savetxt(csv_path, np.column_stack([t, volts]), + delimiter=",", fmt="%.6e") + + rigol.write(":RUN") + vpp_mV = float((volts.max() - volts.min()) * 1000) + mean_V = float(volts.mean()) + return csv_path, vpp_mV, mean_V + + +# --------------------------------------------------------------------------- +# SN65 state extraction +# --------------------------------------------------------------------------- +def pll_state(data: dict | None): + if not isinstance(data, dict): + return None + regs = data.get("registers", {}) + if not isinstance(regs, dict): + return None + csr_0a = regs.get("csr_0a") or {} + return csr_0a.get("pll_lock") + + +# --------------------------------------------------------------------------- +# Non-blocking keys +# --------------------------------------------------------------------------- +class KeyReader: + def __enter__(self): + self.fd = sys.stdin.fileno() + self.old = termios.tcgetattr(self.fd) + tty.setcbreak(self.fd) + return self + + def get_key(self) -> str | None: + if select.select([sys.stdin], [], [], 0)[0]: + return sys.stdin.read(1).lower() + return None + + def __exit__(self, *_): + termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--test", action="store_true", + help="Take one immediate trace + exit (verifies Rigol comms)") + args = ap.parse_args() + + DATA_DIR.mkdir(parents=True, exist_ok=True) + sess = requests.Session() + + print(f"RAIL WATCH") + print(f" sn65 endpoint: {SN65_EP}") + print(f" Rigol IP: {RIGOL_IP}") + print(f" Output dir: {DATA_DIR.relative_to(DATA_DIR.parent.parent)}") + + try: + rigol = vxi11.Instrument(RIGOL_IP) + rigol.timeout = RIGOL_TO_S + idn = rigol.ask("*IDN?").strip() + print(f" Rigol IDN: {idn}") + except Exception as e: + print(f" *** RIGOL CONNECTION FAILED: {e} ***") + sys.exit(1) + + if args.test: + print("\n--test: taking one capture now...") + try: + path, vpp, mean = capture_trace(rigol, "test") + print(f" saved {path.name}") + print(f" Vpp = {vpp:.1f} mV mean = {mean:.3f} V") + except Exception as e: + print(f" CAPTURE FAILED: {e}") + sys.exit(0) + + def _shutdown(*_): + try: + rigol.write(":RUN") + except Exception: + pass + print("\nstopped — Rigol restored to RUN") + sys.exit(0) + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + print("\nkeys: g=baseline capture q=quit\n", flush=True) + print(f" {'time':<14} {'event':<12} {'file':<40} {'Vpp':>7} {'mean':>7}") + print(f" {'-'*14} {'-'*12} {'-'*40} {'-'*7} {'-'*7}") + + last_pll = None + unlock_count = 0 + baseline_count = 0 + err_count = 0 + + with KeyReader() as keys: + while True: + t0 = time.time() + try: + r = sess.get(SN65_EP, timeout=HTTP_TO_S) + r.raise_for_status() + pll = pll_state(r.json()) + err_count = 0 + except Exception: + pll = None + err_count += 1 + + # Trigger Rigol on True → False (a real unlock). We ignore the + # True → None case (transient I2C read failure) since it isn't + # a PLL state change. + if last_pll is True and pll is False: + unlock_count += 1 + iso = datetime.now().strftime("%H:%M:%S.%f")[:-3] + try: + path, vpp, mean = capture_trace( + rigol, f"unlock_{unlock_count:04d}") + print(f" {iso:<14} {'UNLOCK':<12} " + f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V", + flush=True) + except Exception as e: + print(f" {iso:<14} UNLOCK CAPTURE FAILED: {e}", + flush=True) + + last_pll = pll if pll is not None else last_pll + + # Manual baseline capture + key = keys.get_key() + if key == "g": + baseline_count += 1 + iso = datetime.now().strftime("%H:%M:%S.%f")[:-3] + try: + path, vpp, mean = capture_trace( + rigol, f"baseline_{baseline_count:04d}") + print(f" {iso:<14} {'BASELINE':<12} " + f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V", + flush=True) + except Exception as e: + print(f" {iso:<14} BASELINE CAPTURE FAILED: {e}", + flush=True) + elif key == "q": + _shutdown() + + # Pace + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/trial_runner.py b/trial_runner.py new file mode 100644 index 0000000..241f647 --- /dev/null +++ b/trial_runner.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +""" +trial_runner.py — Controlled single-trial flicker experiment. + +Each trial is one labelled load/unload cycle: + + 1. start video (PUT /video start, static-pink) + 2. observe for OBSERVE_S seconds + - poll SN65 PLL state at ~50 Hz, log every state change + 3. snapshot Rigol CH1 (1V8 rail) — one trace per trial + 4. stop video (PUT /video stop) + 5. prompt for label ([f]licker / [g]ood / [s]kip / [q]uit) + 6. save trial JSON + rail CSV with the label + 7. brief pause, then next trial + +Output layout: + data/trials/{session_ts}/ + trial_0001_good_{ts}.json + trial_0001_good_{ts}_rail.csv + trial_0002_flicker_{ts}.json + trial_0002_flicker_{ts}_rail.csv + ... + summary.csv (one row per trial: label, n_unlocks, vpp_mV, mean_V) + +Prerequisites: + * Rigol DS1202Z-E at 192.168.45.5, CH1 probed on 1V8 rail + (script configures channel/timebase/trigger automatically) + * Keysight DSO80204B at 192.168.45.4 with CH1=CLK+, CH3=DAT0+ (CH2/CH4 + = the complementary differential lines; script configures the rest) + * SN65 device endpoint at http://192.168.45.8:5000 +""" + +from __future__ import annotations + +import argparse +import csv +import json +import signal +import sys +import threading +import time +from datetime import datetime +from pathlib import Path + +import numpy as np +import requests +import vxi11 + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DEVICE_BASE = "http://192.168.45.8:5000" +SN65_EP = f"{DEVICE_BASE}/sn65_registers" +VIDEO_URL = f"{DEVICE_BASE}/video" +RIGOL_IP = "192.168.45.5" +KEYSIGHT_IP = "192.168.45.4" +DATA_ROOT = Path(__file__).parent / "data" / "trials" + +OBSERVE_S = 10.0 # observe window per trial +PAUSE_BETWEEN_S = 0.5 +POLL_DT_S = 0.020 # 50 Hz SN65 polling during the observe window +HTTP_TO_S = 0.2 +RIGOL_TO_S = 10.0 +KEYSIGHT_TO_S = 30.0 + +# ---- Rigol CH1 (1V8 rail) capture settings --------------------------------- +# 100 mV/div, offset −1.8 V puts 1.8 V at screen centre with ±400 mV headroom. +# 10 ms/div × 12 div = 120 ms window — comfortably brackets a ~40 ms unlock. +RIGOL_V_SCALE = 0.1 # V/div +RIGOL_V_OFFSET = -1.8 # V +RIGOL_TIMEBASE = 10e-3 # s/div → 120 ms window +RIGOL_PROBE = 10 # 10× passive probe on 1V8 rail + +# ---- Keysight LP-mode capture settings (mirrors flicker_watch.py LP_DAT) --- +KS_LP_SCALE = 1e-6 # 1 µs/div → 20 µs window +KS_LP_POINTS = 50_000 +KS_LP_TRIG_OFFSET = 9e-6 +KS_LP_V_SCALE = 0.2 +KS_LP_V_OFFSET = 0.6 +KS_LP_TRIG_LEVEL = 0.6 +KS_SEGMENT_COUNT = 100 # segments per :DIGitize +KS_PROBE = 19.2 # matches existing test rig + + +# --------------------------------------------------------------------------- +# Rigol I/O +# --------------------------------------------------------------------------- +def _read_ieee_block(rigol) -> bytes: + head = rigol.read_raw(2) + if not head.startswith(b"#"): + idx = head.find(b"#") + if idx < 0: + extra = rigol.read_raw(64) + head += extra + idx = head.find(b"#") + head = head[idx:idx + 2] + ndigits = int(head[1:2]) + length_bytes = rigol.read_raw(ndigits) + nbytes = int(length_bytes) + data = b"" + while len(data) < nbytes: + chunk = rigol.read_raw(nbytes - len(data)) + if not chunk: + break + data += chunk + try: + rigol.read_raw(1) + except Exception: + pass + return data + + +def setup_rigol(rigol) -> None: + """One-shot SCPI configuration of Rigol CH1 for 1V8 supply rail capture.""" + rigol.write(":STOP"); time.sleep(0.2) + rigol.write(":CHANnel1:DISPlay 1") + rigol.write(":CHANnel1:COUPling DC") + rigol.write(f":CHANnel1:PROBe {RIGOL_PROBE}") + rigol.write(f":CHANnel1:SCALe {RIGOL_V_SCALE:.3f}") + rigol.write(f":CHANnel1:OFFSet {RIGOL_V_OFFSET:.3f}") + rigol.write(":CHANnel2:DISPlay 0") + rigol.write(f":TIMebase:MAIN:SCALe {RIGOL_TIMEBASE:.3E}") + rigol.write(":TRIGger:MODE EDGE") + rigol.write(":TRIGger:EDGe:SOURce CHANnel1") + rigol.write(":TRIGger:EDGe:SLOPe NEGative") + rigol.write(":TRIGger:EDGe:LEVel 1.76") + rigol.write(":TRIGger:SWEep AUTO") + rigol.write(":ACQuire:MDEPth AUTO") + time.sleep(0.3) + rigol.write(":RUN") + time.sleep(0.2) + + +_rail_diag_printed = False + + +def capture_rail(rigol, out_path: Path) -> tuple[float, float]: + """:STOP → read CH1 (ASCII format) → :RUN. Returns (vpp_mV, mean_V). + + ASCII format returns volts directly — sidesteps the BYTE-format + YOrigin/YReference unit ambiguity in the Rigol manual. Mirrors the + proven rigol_scope.py approach used in mipi_test.py. + """ + global _rail_diag_printed + + rigol.write(":STOP") + time.sleep(0.1) + rigol.write(":WAVeform:SOURce CHANnel1") + rigol.write(":WAVeform:FORMat ASC") # Rigol DS1000Z uses ASC not ASCII + rigol.write(":WAVeform:MODE NORM") + time.sleep(0.05) + + pre = rigol.ask(":WAVeform:PREamble?").strip().split(",") + xinc = float(pre[4]) + xorig = float(pre[5]) + + raw = rigol.ask(":WAVeform:DATA?").strip() + # Strip optional IEEE 488.2 binary header '#' + if raw.startswith("#"): + ndig = int(raw[1]) + raw = raw[2 + ndig:] + vals = [float(v) for v in raw.split(",") if v.strip()] + if not vals: + rigol.write(":RUN") + raise RuntimeError("Rigol returned no samples (channel disabled?)") + + volts = np.asarray(vals, dtype=np.float64) + t = np.arange(len(volts)) * xinc + xorig + + # One-time diagnostic: dump preamble + raw sample range so we can spot + # probe / channel-setting issues immediately. + if not _rail_diag_printed: + _rail_diag_printed = True + print(f" [diag] Rigol preamble: pts={pre[2]} xinc={xinc:.2e} " + f"xorig={xorig:.2e}") + print(f" [diag] first 5 samples (V): " + f"{[round(v, 4) for v in volts[:5].tolist()]}") + print(f" [diag] sample range: " + f"min={volts.min():.4f} V, max={volts.max():.4f} V, " + f"n={len(volts)}") + + np.savetxt(out_path, np.column_stack([t, volts]), + delimiter=",", fmt="%.6e") + rigol.write(":RUN") + return float((volts.max() - volts.min()) * 1000), float(volts.mean()) + + +# --------------------------------------------------------------------------- +# Keysight DSO80204B (MIPI scope) I/O — mirrors flicker_watch.py LP_DAT mode +# --------------------------------------------------------------------------- +def _ks_drain_errors(scope) -> list[str]: + errs = [] + for _ in range(20): + try: + r = scope.ask(":SYSTem:ERRor?").strip() + except Exception: + break + if not r or r.startswith(("0,", "+0,")) or r == "0": + break + errs.append(r) + return errs + + +def setup_keysight(scope) -> None: + """Configure Keysight scope for MIPI LP-mode segmented LP_DAT capture.""" + cmds = [ + "*RST", ":RUN", ":STOP", "*CLS", + ":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50", + f":CHANnel1:PROBe {KS_PROBE}", ":CHANnel1:LABel 'CLK+'", + ":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50", + f":CHANnel2:PROBe {KS_PROBE}", ":CHANnel2:LABel 'CLK-'", + ":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50", + f":CHANnel3:PROBe {KS_PROBE}", ":CHANnel3:LABel 'DAT0+'", + ":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50", + f":CHANnel4:PROBe {KS_PROBE}", ":CHANnel4:LABel 'DAT0-'", + ":TIMebase:REFerence CENTer", + ":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON", + ] + for c in cmds: + scope.write(c) + time.sleep(0.04) + _ks_drain_errors(scope) + + # LP-mode channel offsets + falling-edge trigger on DAT0+ + for ch in (1, 2, 3, 4): + scope.write(f":CHANnel{ch}:SCALe {KS_LP_V_SCALE:.3f}") + scope.write(f":CHANnel{ch}:OFFSet {KS_LP_V_OFFSET:.3f}") + scope.write(":TRIGger:MODE EDGE") + scope.write(":TRIGger:EDGE:SOURce CHANnel3") + scope.write(":TRIGger:EDGE:SLOPe NEGative") + scope.write(f":TRIGger:EDGE:LEVel {KS_LP_TRIG_LEVEL:.3f}") + scope.write(":TRIGger:SWEep NORMal") + scope.write(f":TIMebase:SCALe {KS_LP_SCALE:.3E}") + scope.write(f":ACQuire:POINts {KS_LP_POINTS}") + scope.write(f":TIMebase:POSition {KS_LP_TRIG_OFFSET:.2E}") + scope.write(":ACQuire:MODE SEGMented") + scope.write(f":ACQuire:SEGMented:COUNt {KS_SEGMENT_COUNT}") + time.sleep(0.4) + _ks_drain_errors(scope) + + +def _ks_read_block(scope) -> bytes: + """IEEE 488.2 binary block: '#'[\\n].""" + head = scope.read_raw(2) + if not head.startswith(b"#"): + idx = head.find(b"#") + if idx < 0: + extra = scope.read_raw(64) + head += extra + idx = head.find(b"#") + head = head[idx:idx + 2] + ndigits = int(head[1:2]) + length_bytes = scope.read_raw(ndigits) + nbytes = int(length_bytes) + data = b"" + while len(data) < nbytes: + chunk = scope.read_raw(nbytes - len(data)) + if not chunk: + break + data += chunk + try: + scope.read_raw(1) + except Exception: + pass + return data + + +def keysight_arm(scope) -> None: + """Send :DIGitize. Acquisition runs in scope memory until OPC.""" + scope.write(":DIGitize") + + +def keysight_wait_done(scope, timeout_s: float) -> bool: + """Block until acquisition completes or timeout.""" + prev = scope.timeout + try: + scope.timeout = timeout_s + 2 + return scope.ask("*OPC?").strip() == "1" + except Exception: + return False + finally: + scope.timeout = prev + + +def keysight_read_segments(scope, n_segments: int): + """Read CLK+ (CH1) and DAT0+ (CH3) for all N segments via :WAVeform:DATA?.""" + out = {} + for chan_id, label in [(1, "clk"), (3, "dat")]: + scope.write(f":WAVeform:SOURce CHANnel{chan_id}") + scope.write(":WAVeform:FORMat WORD") + scope.write(":WAVeform:BYTeorder LSBFirst") + x_inc = float(scope.ask(":WAVeform:XINCrement?")) + x_org = float(scope.ask(":WAVeform:XORigin?")) + y_inc = float(scope.ask(":WAVeform:YINCrement?")) + y_org = float(scope.ask(":WAVeform:YORigin?")) + segs = [] + for i in range(1, n_segments + 1): + if n_segments > 1: + scope.write(f":ACQuire:SEGMented:INDex {i}") + scope.write(":WAVeform:DATA?") + raw = _ks_read_block(scope) + codes = np.frombuffer(raw, dtype=" int: + """Write per-segment CSVs to out_dir. Returns number of segments written.""" + n_written = 0 + n_segs = len(segments["clk"]["segs"]) + for i in range(n_segs): + for label in ("clk", "dat"): + t = segments[label]["times"] + v = segments[label]["segs"][i] + path = out_dir / f"{base}_seg{i+1:03d}_{label}.csv" + np.savetxt(path, np.column_stack([t, v]), + delimiter=",", fmt="%.6e") + n_written += 1 + return n_written + + +# --------------------------------------------------------------------------- +# Video + SN65 helpers +# --------------------------------------------------------------------------- +def video_start(sess: requests.Session) -> None: + try: + sess.put(VIDEO_URL, + json={"action": "start", "mode": "static-pink"}, timeout=3.0) + except Exception as e: + print(f" video START failed: {e}") + + +def video_stop(sess: requests.Session) -> None: + try: + sess.put(VIDEO_URL, json={"action": "stop"}, timeout=3.0) + except Exception as e: + print(f" video STOP failed: {e}") + + +def extract_state(data: dict | None) -> dict: + regs = (data or {}).get("registers", {}) or {} + csr_0a = regs.get("csr_0a") or {} + csr_e5 = regs.get("csr_e5") or {} + return { + "csr_0a": csr_0a.get("value"), + "csr_e5": csr_e5.get("value"), + "pll_lock": csr_0a.get("pll_lock"), + "clk_det": csr_0a.get("clk_det"), + "pll_unlock": csr_e5.get("pll_unlock"), + "cha_sot_bit_err":csr_e5.get("cha_sot_bit_err"), + "cha_llp_err": csr_e5.get("cha_llp_err"), + "cha_ecc_err": csr_e5.get("cha_ecc_err"), + "cha_lp_err": csr_e5.get("cha_lp_err"), + "cha_crc_err": csr_e5.get("cha_crc_err"), + } + + +def observe_window(sess: requests.Session, duration_s: float) -> tuple[list, list]: + """ + Poll SN65 for `duration_s` at POLL_DT_S. Return (all_samples, unlocks). + + `unlocks` is a list of pll_lock True→False events (timestamps only — paired + recovery times are stitched in post). + """ + samples: list = [] + unlocks: list = [] + last_pll: bool | None = None + end = time.time() + duration_s + + while time.time() < end: + t0 = time.time() + try: + r = sess.get(SN65_EP, timeout=HTTP_TO_S) + r.raise_for_status() + state = extract_state(r.json()) + samples.append({"ts": t0, "state": state}) + pll = state["pll_lock"] + if last_pll is True and pll is False: + unlocks.append({"ts": t0, + "iso": datetime.fromtimestamp(t0) + .strftime("%H:%M:%S.%f")[:-3]}) + if pll is not None: + last_pll = pll + except Exception as e: + samples.append({"ts": t0, "error": str(e)}) + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + return samples, unlocks + + +class SN65Poller(threading.Thread): + """ + Background SN65 poller — runs for the full duration of a trial + (video_start … video_stop) so we never have a coverage gap. + Uses its own requests.Session because requests.Session isn't + thread-safe for sharing with the main thread's HTTP calls. + """ + def __init__(self): + super().__init__(daemon=True) + self._sess = requests.Session() + self._stop_evt = threading.Event() # NOT _stop: Thread uses that + self._lock = threading.Lock() + self.samples: list = [] + self.unlocks: list = [] + + def request_stop(self): + self._stop_evt.set() + + def snapshot(self) -> tuple[list, list]: + """Return shallow copies of (samples, unlocks) so the main thread can + keep mutating them safely after the poller has stopped.""" + with self._lock: + return list(self.samples), list(self.unlocks) + + def run(self): + last_pll: bool | None = None + while not self._stop_evt.is_set(): + t0 = time.time() + try: + r = self._sess.get(SN65_EP, timeout=HTTP_TO_S) + r.raise_for_status() + state = extract_state(r.json()) + pll = state["pll_lock"] + rec = {"ts": t0, "state": state} + if last_pll is True and pll is False: + self.unlocks.append({ + "ts": t0, + "iso": datetime.fromtimestamp(t0) + .strftime("%H:%M:%S.%f")[:-3], + }) + if pll is not None: + last_pll = pll + except Exception as e: + rec = {"ts": t0, "error": str(e)} + with self._lock: + self.samples.append(rec) + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + + +def prompt_label(default: str = "g") -> str: + """Block until user enters f/g/s/q.""" + while True: + try: + ans = input("\n label? [f]licker / [g]ood / [s]kip / [q]uit: " + ).strip().lower() + except EOFError: + return "q" + if ans == "": + ans = default + if ans in ("f", "g", "s", "q"): + return ans + print(f" not understood ('{ans}') — try again") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--observe-s", type=float, default=OBSERVE_S, + help=f"observe window per trial in seconds (default {OBSERVE_S})") + ap.add_argument("--no-rigol", action="store_true", + help="skip Rigol rail capture (useful if scope not connected)") + ap.add_argument("--no-keysight", action="store_true", + help="skip Keysight MIPI capture (useful if scope not connected)") + args = ap.parse_args() + + session_ts = datetime.now().strftime("%Y%m%d_%H%M%S") + session_dir = DATA_ROOT / session_ts + session_dir.mkdir(parents=True, exist_ok=True) + summary_path = session_dir / "summary.csv" + + print(f"TRIAL RUNNER — session {session_ts}") + print(f" output: {session_dir.relative_to(DATA_ROOT.parent.parent)}") + print(f" observe: {args.observe_s:.1f} s") + print(f" SN65 endpoint: {SN65_EP}") + + sess = requests.Session() + + # Verify SN65 endpoint + try: + sess.get(SN65_EP, timeout=2.0).raise_for_status() + print(f" SN65: reachable") + except Exception as e: + print(f" *** SN65 endpoint failed: {e} ***") + sys.exit(1) + + rigol = None + if not args.no_rigol: + try: + rigol = vxi11.Instrument(RIGOL_IP) + rigol.timeout = RIGOL_TO_S + idn = rigol.ask("*IDN?").strip() + print(f" Rigol: {idn}") + setup_rigol(rigol) + print(f" CH1 configured: {RIGOL_V_SCALE*1000:.0f} mV/div, " + f"offset {RIGOL_V_OFFSET:.2f} V, {RIGOL_TIMEBASE*1000:.1f} ms/div") + except Exception as e: + print(f" Rigol unreachable ({e}) — continuing without rail capture") + rigol = None + else: + print(f" Rigol: disabled (--no-rigol)") + + scope = None + if not args.no_keysight: + try: + scope = vxi11.Instrument(KEYSIGHT_IP) + scope.timeout = KEYSIGHT_TO_S + idn = scope.ask("*IDN?").strip() + print(f" Keysight: {idn}") + setup_keysight(scope) + print(f" LP_DAT segmented, {KS_SEGMENT_COUNT} segs/acquire, " + f"{KS_LP_POINTS} pts × {KS_LP_SCALE*1e6:.0f} µs/div") + except Exception as e: + print(f" Keysight unreachable ({e}) — continuing without MIPI capture") + scope = None + else: + print(f" Keysight: disabled (--no-keysight)") + + # Open summary CSV + sf = open(summary_path, "w", newline="") + sw = csv.writer(sf) + sw.writerow(["trial", "iso", "label", "n_unlocks", + "min_unlock_ms", "med_unlock_ms", "max_unlock_ms", + "rail_vpp_mV", "rail_mean_V", + "n_keysight_segs", "json_file"]) + sf.flush() + + def _shutdown(*_): + try: + video_stop(sess) + except Exception: + pass + try: + sf.close() + except Exception: + pass + if rigol is not None: + try: + rigol.write(":RUN") + except Exception: + pass + print("\nshutting down — video off, Rigol restored to RUN") + sys.exit(0) + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + print("\n Watch the display during each observe window, then label the trial.") + print() + trial = 0 + while True: + trial += 1 + trial_iso = datetime.now().strftime("%Y%m%d_%H%M%S") + print(f"=== TRIAL {trial:04d} {trial_iso} ===", flush=True) + + # Start background SN65 poller — runs continuously through the entire + # trial (observe + Rigol read + MIPI read + video_stop) so we don't + # miss any unlock that falls in the readout/transition phases. + poller = SN65Poller() + poller.start() + + # 1) start video + print(f" video START", flush=True) + video_start(sess) + t_video_on = time.time() + + # 2a) Kick off Keysight acquire (non-blocking — runs in scope memory). + if scope is not None: + try: + keysight_arm(scope) + except Exception as e: + print(f" Keysight arm FAILED: {e}", flush=True) + + # 2b) Observe phase — main thread just sleeps while poller does its job + print(f" observing for {args.observe_s:.0f} s ...", flush=True) + time.sleep(args.observe_s) + + # 3) Rigol rail snapshot — poller continues in background + vpp_mV = mean_V = None + rail_path = None + if rigol is not None: + rail_path = session_dir / f"trial_{trial:04d}_{trial_iso}_rail.csv" + try: + vpp_mV, mean_V = capture_rail(rigol, rail_path) + print(f" rail: Vpp={vpp_mV:.1f} mV mean={mean_V:.3f} V", flush=True) + except Exception as e: + print(f" rail capture FAILED: {e}", flush=True) + rail_path = None + + # 4) Read Keysight segments (poller continues in background) + n_ks_segs = 0 + if scope is not None: + try: + if keysight_wait_done(scope, timeout_s=5.0): + segs = keysight_read_segments(scope, KS_SEGMENT_COUNT) + base = f"trial_{trial:04d}_{trial_iso}_mipi" + n_ks_segs = save_keysight_segments(segs, session_dir, base) + print(f" MIPI: {n_ks_segs} segments saved " + f"(base {base}_segNNN_clk.csv / _dat.csv)", flush=True) + else: + print(f" Keysight acquisition didn't complete in time", flush=True) + except Exception as e: + print(f" Keysight read FAILED: {e}", flush=True) + + # 5) stop video — poller still running so we catch any unlock at the + # moment of video stop (which we missed in the previous design) + print(f" video STOP", flush=True) + video_stop(sess) + # Brief tail so the post-stop transition is included in the poll window + time.sleep(0.5) + + # Stop poller and harvest its data + poller.request_stop() + poller.join(timeout=2.0) + samples, unlocks = poller.snapshot() + n_errors = sum(1 for s in samples if "error" in s) + n_none = sum(1 for s in samples + if "state" in s and s["state"].get("pll_lock") is None) + print(f" SN65 polled: {len(samples)} samples " + f"(over ~{args.observe_s + 6:.0f}s) " + f"errors={n_errors} None={n_none}", flush=True) + + # Pair unlocks with their recovery times for pulse-width measurement + unlock_pairs = [] + pll_evts = [s for s in samples + if "state" in s and s["state"].get("pll_lock") is not None] + for u in unlocks: + # Find next sample where pll_lock is True after this unlock ts + for s in pll_evts: + if s["ts"] > u["ts"] and s["state"]["pll_lock"] is True: + dur = (s["ts"] - u["ts"]) * 1000.0 + unlock_pairs.append({"start_ts": u["ts"], + "start_iso": u["iso"], + "duration_ms": dur}) + break + + durs = sorted(p["duration_ms"] for p in unlock_pairs) + if durs: + n = len(durs) + mn, md, mx = durs[0], durs[n//2], durs[-1] + print(f" unlocks: {len(unlock_pairs)} durations: " + f"min={mn:.1f}ms med={md:.1f}ms max={mx:.1f}ms", flush=True) + else: + mn = md = mx = None + print(f" unlocks: 0", flush=True) + + # 6) prompt for label + label_short = prompt_label() + if label_short == "q": + _shutdown() + if label_short == "s": + print(f" skipped (no save)") + time.sleep(PAUSE_BETWEEN_S) + trial -= 1 # don't number this one + continue + label = {"f": "flicker", "g": "good"}[label_short] + + # 7) save trial JSON + summary row + json_path = session_dir / f"trial_{trial:04d}_{label}_{trial_iso}.json" + trial_data = { + "trial": trial, + "session_ts": session_ts, + "trial_ts": trial_iso, + "label": label, + "observe_s": args.observe_s, + "t_video_on": t_video_on, + "n_samples": len(samples), + "n_unlocks": len(unlock_pairs), + "unlock_pairs": unlock_pairs, + "samples": samples, + "rail_csv": rail_path.name if rail_path else None, + "rail_vpp_mV": vpp_mV, + "rail_mean_V": mean_V, + "n_keysight_segs": n_ks_segs, + "keysight_basename": f"trial_{trial:04d}_{trial_iso}_mipi" if n_ks_segs else None, + } + json_path.write_text(json.dumps(trial_data, indent=2, default=str)) + print(f" saved {json_path.name}", flush=True) + + sw.writerow([trial, trial_iso, label, len(unlock_pairs), + f"{mn:.1f}" if mn is not None else "", + f"{md:.1f}" if md is not None else "", + f"{mx:.1f}" if mx is not None else "", + f"{vpp_mV:.1f}" if vpp_mV is not None else "", + f"{mean_V:.3f}" if mean_V is not None else "", + n_ks_segs, + json_path.name]) + sf.flush() + + time.sleep(PAUSE_BETWEEN_S) + print() # blank line between trials + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unlock_capture.py b/unlock_capture.py new file mode 100644 index 0000000..fd46064 --- /dev/null +++ b/unlock_capture.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +""" +unlock_capture.py — capture 1V8 rail + MIPI CLK every time the SN65 reports +a PLL unlock. + +Architecture +------------ +- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions. +- On each unlock, immediately: + 1. :STOP the Rigol DS1202Z-E and read CH1 (1V8 rail). + Rigol runs with a 120 ms window (10 ms/div × 12) so the rail trace + brackets the ~20 ms unlock. + 2. Read 100 segmented MIPI captures from the Keysight DSO80204B. + Each segment is 20 µs of CLK+ and DAT0+. Spread across the recent + ~seconds — *most segments will not land in the unlock instant*, but + collectively they prove the MIPI signal stays clean around unlocks. + 3. Restart both scopes for the next event. +- Press `g` to capture a baseline pair manually (for clean comparison). +- Press `c` to capture a catastrophic-event snapshot — for when you observe + the black-screen failure (which doesn't manifest as a PLL unlock and so + isn't automatically captured). +- Press `q` to quit. + +Pairs nicely with `video_cycler.py --hold` (continuous video, no cycling) +*or* `video_cycler.py` (with cycling) to provoke unlocks more often. + +Output layout: + data/unlock_captures/{session_ts}/ + unlock_0001_{ts}_rail.csv + unlock_0001_{ts}_mipi_seg001_clk.csv ... seg100_dat.csv + unlock_0001_{ts}_meta.json + ... + summary.csv +""" + +from __future__ import annotations + +import argparse +import csv +import json +import select +import signal +import sys +import termios +import time +import tty +from datetime import datetime +from pathlib import Path + +import numpy as np +import requests +import vxi11 + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DEVICE_BASE = "http://192.168.45.8:5000" +SN65_EP = f"{DEVICE_BASE}/sn65_registers" +RIGOL_IP = "192.168.45.5" +KEYSIGHT_IP = "192.168.45.4" +DATA_ROOT = Path(__file__).parent / "data" / "unlock_captures" + +POLL_DT_S = 0.020 # 50 Hz SN65 polling +HTTP_TO_S = 0.2 +RIGOL_TO_S = 10.0 +KEYSIGHT_TO_S = 30.0 + +# Rigol CH1 settings — wider window catches a burst of flickers in one trace +RIGOL_V_SCALE = 0.1 # V/div +RIGOL_V_OFFSET = -1.8 # V +RIGOL_TIMEBASE = 500e-3 # s/div → 6 s window +RIGOL_PROBE = 10 + +# Keysight LP_DAT segmented capture +KS_LP_SCALE = 1e-6 +KS_LP_POINTS = 50_000 +KS_LP_TRIG_OFFSET = 9e-6 +KS_LP_V_SCALE = 0.2 +KS_LP_V_OFFSET = 0.6 +KS_LP_TRIG_LEVEL = 0.6 +KS_SEGMENT_COUNT = 20 # ~2 s capture cycle (was 100 → ~10 s) +KS_PROBE = 19.2 + +ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err", + "cha_ecc_err", "cha_lp_err", "cha_crc_err") + + +# --------------------------------------------------------------------------- +# Non-blocking keys +# --------------------------------------------------------------------------- +class KeyReader: + def __enter__(self): + self.fd = sys.stdin.fileno() + self.old = termios.tcgetattr(self.fd) + tty.setcbreak(self.fd) + return self + + def get_key(self) -> str | None: + if select.select([sys.stdin], [], [], 0)[0]: + return sys.stdin.read(1).lower() + return None + + def __exit__(self, *_): + termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old) + + +# --------------------------------------------------------------------------- +# SN65 extraction +# --------------------------------------------------------------------------- +def extract_state(data: dict | None) -> dict: + regs = (data or {}).get("registers", {}) or {} + csr_0a = regs.get("csr_0a") or {} + csr_e5 = regs.get("csr_e5") or {} + state = { + "csr_0a": csr_0a.get("value"), + "csr_e5": csr_e5.get("value"), + "pll_lock": csr_0a.get("pll_lock"), + "clk_det": csr_0a.get("clk_det"), + } + for k in ERROR_BITS: + state[k] = csr_e5.get(k) + return state + + +# --------------------------------------------------------------------------- +# Rigol I/O +# --------------------------------------------------------------------------- +def setup_rigol(rigol) -> None: + rigol.write(":STOP"); time.sleep(0.2) + rigol.write(":CHANnel1:DISPlay 1") + rigol.write(":CHANnel1:COUPling DC") + rigol.write(f":CHANnel1:PROBe {RIGOL_PROBE}") + rigol.write(f":CHANnel1:SCALe {RIGOL_V_SCALE:.3f}") + rigol.write(f":CHANnel1:OFFSet {RIGOL_V_OFFSET:.3f}") + rigol.write(":CHANnel2:DISPlay 0") + rigol.write(f":TIMebase:MAIN:SCALe {RIGOL_TIMEBASE:.3E}") + rigol.write(":TRIGger:MODE EDGE") + rigol.write(":TRIGger:EDGe:SOURce CHANnel1") + rigol.write(":TRIGger:EDGe:SLOPe NEGative") + rigol.write(":TRIGger:EDGe:LEVel 1.76") + rigol.write(":TRIGger:SWEep AUTO") + rigol.write(":ACQuire:MDEPth AUTO") + time.sleep(0.3); rigol.write(":RUN"); time.sleep(0.2) + + +def capture_rail(rigol, out_path: Path) -> tuple[float, float]: + rigol.write(":STOP"); time.sleep(0.1) + rigol.write(":WAVeform:SOURce CHANnel1") + rigol.write(":WAVeform:FORMat ASC") + rigol.write(":WAVeform:MODE NORM") + time.sleep(0.05) + pre = rigol.ask(":WAVeform:PREamble?").strip().split(",") + xinc = float(pre[4]); xorig = float(pre[5]) + raw = rigol.ask(":WAVeform:DATA?").strip() + if raw.startswith("#"): + ndig = int(raw[1]) + raw = raw[2 + ndig:] + vals = [float(v) for v in raw.split(",") if v.strip()] + if not vals: + rigol.write(":RUN") + raise RuntimeError("Rigol returned no samples") + volts = np.asarray(vals, dtype=np.float64) + t = np.arange(len(volts)) * xinc + xorig + np.savetxt(out_path, np.column_stack([t, volts]), + delimiter=",", fmt="%.6e") + rigol.write(":RUN") + return float((volts.max() - volts.min()) * 1000), float(volts.mean()) + + +# --------------------------------------------------------------------------- +# Keysight I/O (mirrors trial_runner.py) +# --------------------------------------------------------------------------- +def _ks_drain(scope): + for _ in range(20): + try: + r = scope.ask(":SYSTem:ERRor?").strip() + except Exception: + return + if not r or r.startswith(("0,", "+0,")) or r == "0": + return + + +def setup_keysight(scope) -> None: + for c in [ + "*RST", ":RUN", ":STOP", "*CLS", + ":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50", + f":CHANnel1:PROBe {KS_PROBE}", ":CHANnel1:LABel 'CLK+'", + ":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50", + f":CHANnel2:PROBe {KS_PROBE}", ":CHANnel2:LABel 'CLK-'", + ":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50", + f":CHANnel3:PROBe {KS_PROBE}", ":CHANnel3:LABel 'DAT0+'", + ":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50", + f":CHANnel4:PROBe {KS_PROBE}", ":CHANnel4:LABel 'DAT0-'", + ":TIMebase:REFerence CENTer", + ":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON", + ]: + scope.write(c); time.sleep(0.04) + _ks_drain(scope) + for ch in (1, 2, 3, 4): + scope.write(f":CHANnel{ch}:SCALe {KS_LP_V_SCALE:.3f}") + scope.write(f":CHANnel{ch}:OFFSet {KS_LP_V_OFFSET:.3f}") + scope.write(":TRIGger:MODE EDGE") + scope.write(":TRIGger:EDGE:SOURce CHANnel3") + scope.write(":TRIGger:EDGE:SLOPe NEGative") + scope.write(f":TRIGger:EDGE:LEVel {KS_LP_TRIG_LEVEL:.3f}") + scope.write(":TRIGger:SWEep NORMal") + scope.write(f":TIMebase:SCALe {KS_LP_SCALE:.3E}") + scope.write(f":ACQuire:POINts {KS_LP_POINTS}") + scope.write(f":TIMebase:POSition {KS_LP_TRIG_OFFSET:.2E}") + scope.write(":ACQuire:MODE SEGMented") + scope.write(f":ACQuire:SEGMented:COUNt {KS_SEGMENT_COUNT}") + time.sleep(0.4) + _ks_drain(scope) + + +def _ks_read_block(scope) -> bytes: + head = scope.read_raw(2) + if not head.startswith(b"#"): + idx = head.find(b"#") + if idx < 0: + extra = scope.read_raw(64) + head += extra + idx = head.find(b"#") + head = head[idx:idx + 2] + ndigits = int(head[1:2]) + length_bytes = scope.read_raw(ndigits) + nbytes = int(length_bytes) + data = b"" + while len(data) < nbytes: + chunk = scope.read_raw(nbytes - len(data)) + if not chunk: + break + data += chunk + try: + scope.read_raw(1) + except Exception: + pass + return data + + +def keysight_capture(scope, out_dir: Path, base: str) -> int: + """:DIGitize → read all segments → save CSVs. Returns segments written.""" + prev = scope.timeout + try: + scope.timeout = KEYSIGHT_TO_S + scope.write(":DIGitize") + if scope.ask("*OPC?").strip() != "1": + return 0 + except Exception as e: + print(f" keysight arm/wait failed: {e}") + return 0 + finally: + scope.timeout = prev + + n_written = 0 + for chan_id, label in [(1, "clk"), (3, "dat")]: + scope.write(f":WAVeform:SOURce CHANnel{chan_id}") + scope.write(":WAVeform:FORMat WORD") + scope.write(":WAVeform:BYTeorder LSBFirst") + x_inc = float(scope.ask(":WAVeform:XINCrement?")) + x_org = float(scope.ask(":WAVeform:XORigin?")) + y_inc = float(scope.ask(":WAVeform:YINCrement?")) + y_org = float(scope.ask(":WAVeform:YORigin?")) + for i in range(1, KS_SEGMENT_COUNT + 1): + scope.write(f":ACQuire:SEGMented:INDex {i}") + scope.write(":WAVeform:DATA?") + raw = _ks_read_block(scope) + codes = np.frombuffer(raw, dtype=" None: + """One unlock or baseline capture: Rigol + Keysight + meta JSON.""" + ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + iso = datetime.fromtimestamp(time.time()).strftime("%H:%M:%S.%f")[:-3] + base = f"{event_label}_{event_num:04d}_{ts}" + + # 1. Rigol — fast (~100-300 ms) + rail_path = session_dir / f"{base}_rail.csv" + vpp_mV = mean_V = None + try: + vpp_mV, mean_V = capture_rail(rigol, rail_path) + except Exception as e: + print(f" rail capture FAILED: {e}", flush=True) + rail_path = None + + # 2. Keysight — slow (~5-15 s for 100 segs) + n_segs = 0 + if scope is not None: + try: + n_segs = keysight_capture(scope, session_dir, f"{base}_mipi") + except Exception as e: + print(f" keysight capture FAILED: {e}", flush=True) + + # 3. Meta + meta = { + "event": event_label, + "event_num": event_num, + "ts": ts, + "iso": iso, + "last_pll_state": last_state, + "rail_csv": rail_path.name if rail_path else None, + "rail_vpp_mV": vpp_mV, + "rail_mean_V": mean_V, + "n_mipi_segments": n_segs, + "mipi_basename": f"{base}_mipi" if n_segs else None, + } + meta_path = session_dir / f"{base}_meta.json" + meta_path.write_text(json.dumps(meta, indent=2, default=str)) + + rail_str = (f"Vpp={vpp_mV:.1f}mV mean={mean_V:.3f}V" + if vpp_mV is not None else "RAIL FAILED") + print(f" [{iso}] {event_label.upper():<8} #{event_num:04d} " + f"{rail_str} MIPI={n_segs}segs", flush=True) + + summary_writer.writerow([event_num, ts, iso, event_label, + f"{vpp_mV:.1f}" if vpp_mV is not None else "", + f"{mean_V:.3f}" if mean_V is not None else "", + n_segs, base]) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--no-keysight", action="store_true", + help="Rigol only (skip MIPI capture per event)") + args = ap.parse_args() + + session_ts = datetime.now().strftime("%Y%m%d_%H%M%S") + session_dir = DATA_ROOT / session_ts + session_dir.mkdir(parents=True, exist_ok=True) + + print(f"UNLOCK CAPTURE — session {session_ts}") + print(f" output: {session_dir.relative_to(DATA_ROOT.parent.parent)}") + + # Connect SN65 endpoint + sess = requests.Session() + try: + sess.get(SN65_EP, timeout=2.0).raise_for_status() + print(f" SN65: reachable") + except Exception as e: + print(f" *** SN65 endpoint failed: {e} ***") + sys.exit(1) + + # Connect + configure Rigol + rigol = vxi11.Instrument(RIGOL_IP) + rigol.timeout = RIGOL_TO_S + try: + print(f" Rigol: {rigol.ask('*IDN?').strip()}") + setup_rigol(rigol) + except Exception as e: + print(f" *** Rigol failed: {e} ***") + sys.exit(1) + + # Connect + configure Keysight + scope = None + if not args.no_keysight: + scope = vxi11.Instrument(KEYSIGHT_IP) + scope.timeout = KEYSIGHT_TO_S + try: + print(f" Keysight: {scope.ask('*IDN?').strip()}") + setup_keysight(scope) + except Exception as e: + print(f" Keysight failed ({e}) — continuing without MIPI capture") + scope = None + + summary_path = session_dir / "summary.csv" + sf = open(summary_path, "w", newline="") + sw = csv.writer(sf) + sw.writerow(["event_num", "ts", "iso", "event_label", + "rail_vpp_mV", "rail_mean_V", "n_mipi_segs", "basename"]) + sf.flush() + + def _shutdown(*_): + print("\nshutting down") + try: rigol.write(":RUN") + except Exception: pass + try: sf.close() + except Exception: pass + sys.exit(0) + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + print("\n Capturing 1V8 rail + MIPI segments on every PLL unlock.") + print(" Run video_cycler.py in another terminal to provoke unlocks.") + print(" keys: g=baseline c=catastrophic-event observed q=quit\n") + print(f" {'time':<14} {'event':<15} {'rail':<28} {'mipi':<10}") + print(f" {'-'*14} {'-'*15} {'-'*28} {'-'*10}") + + last_pll = None + last_state = {} + unlock_n = 0 + baseline_n = 0 + catastrophic_n = 0 + err_count = 0 + + with KeyReader() as keys: + while True: + t0 = time.time() + pll = None + try: + r = sess.get(SN65_EP, timeout=HTTP_TO_S) + r.raise_for_status() + last_state = extract_state(r.json()) + pll = last_state["pll_lock"] + err_count = 0 + except Exception: + err_count += 1 + + if last_pll is True and pll is False: + unlock_n += 1 + handle_event("unlock", unlock_n, session_dir, + rigol, scope, sw, last_state) + sf.flush() + + if pll is not None: + last_pll = pll + + key = keys.get_key() + if key == "g": + baseline_n += 1 + handle_event("baseline", baseline_n, session_dir, + rigol, scope, sw, last_state) + sf.flush() + elif key == "c": + catastrophic_n += 1 + print(f"\n *** CATASTROPHIC EVENT OBSERVED — " + f"capturing scopes ***", flush=True) + handle_event("catastrophic", catastrophic_n, session_dir, + rigol, scope, sw, last_state) + sf.flush() + elif key == "q": + _shutdown() + + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + + +if __name__ == "__main__": + main()