Parked for now

This commit is contained in:
david rice
2026-05-15 16:32:15 +01:00
parent d73aa2f2a4
commit 39f4355b8d
21 changed files with 3031 additions and 1 deletions

625
flicker_burst.py Normal file
View File

@@ -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="<i2")
volts = codes.astype(np.float64) * y_inc + y_org
t = np.arange(len(volts)) * x_inc + x_org
path = out_dir / f"{base}_seg{i:03d}_{label}.csv"
np.savetxt(path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
if label == "clk":
n_written += 1
return n_written
# ---------------------------------------------------------------------------
# Capture-and-poll cycle
# ---------------------------------------------------------------------------
def capture_burst(sess, scope, rigol, cycler_tail: CyclerLogTail,
burst_n: int, session_dir: Path,
summary_writer) -> 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()

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -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

812
make_flicker_report.py Normal file
View File

@@ -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 <head> + banner from the existing template so colours/logo match.
The banner has a nested <div class="who">, so we need the SECOND </div>
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("</head>")
body_start = text.find("<body>")
# Walk past two </div> tags to clear the nested "who" div + the banner itself
pos = text.find('class="banner"')
for _ in range(2):
pos = text.find("</div>", pos) + len("</div>")
body_end_banner = pos
return text[:head_end + len("</head>")] + "\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 <img src=...>
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('<div class="page">')
html.append(f'<h1>MIPI DSI Flicker — Hardware Exoneration Test</h1>')
html.append(f'<div class="meta">Session <code>{session_id}</code> &nbsp;·&nbsp; '
f'Report generated {today_iso} &nbsp;·&nbsp; '
f'{n_total} operator-confirmed flicker observations analysed</div>')
# ── TL;DR ──
html.append('<div class="tldr">')
html.append(f'<strong>TL;DR</strong> &nbsp; Across {n_total} operator-confirmed '
f'flicker observations, <strong>{n_with_unlock} ({pct_unlock:.0f}%) '
f'produced detectable SN65 PLL unlocks</strong>; the remaining '
f'{n_no_change} ({100-pct_unlock:.0f}%) showed <strong>no measurable '
f'change</strong> 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 bridges '
f'internal MIPI-to-LVDS logic.</div>')
# ── 1. Method ──
html.append('<h2>1. Method</h2>')
html.append('<p>The <code>flicker_burst.py</code> tool was run alongside '
'<code>video_cycler.py</code>. The operator watched the display while '
'video was cycled on/off and pressed <code>f</code> the instant any '
'visible flicker was observed. Each press triggers a synchronised '
'capture of three independent measurement channels:</p>')
html.append('<table><thead><tr><th>Channel</th><th>Instrument</th><th>What it captures</th></tr></thead><tbody>')
html.append('<tr><td>SN65 PLL state &amp; error bits</td><td>HTTP / I2C</td>'
'<td>Continuous polling at ~50&nbsp;Hz from <code>f</code>-press until '
'<code>video_cycler</code>s next stop event</td></tr>')
html.append('<tr><td>1V8 supply rail</td><td>Rigol DS1202Z-E (CH1)</td>'
'<td>12&nbsp;s window (10&nbsp;ms/div × 12), 100&nbsp;mV/div, '
'1.8&nbsp;V offset, DC coupling, 10× probe</td></tr>')
html.append('<tr><td>MIPI CLK+ &amp; DAT0+</td><td>Keysight DSO80204B</td>'
'<td>100 segments × 20&nbsp;µs at 5&nbsp;GSa/s, LP-edge triggered '
'at line rate (~48&nbsp;kHz)</td></tr>')
html.append('</tbody></table>')
# ── 2. Results table ──
html.append('<h2>2. Per-burst SN65 register summary</h2>')
html.append('<table><thead><tr>'
'<th>Burst</th><th>Press</th><th>Window (s)</th>'
'<th>n samples</th><th>PLL unlocks</th>'
'<th>csr_0a values</th><th>csr_e5 values</th>'
'<th>Rail Vpp / mean</th></tr></thead><tbody>')
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'<tr><td>{r["burst"]}</td><td>{r["press_iso"]}</td>'
f'<td>{r["duration_s"]:.2f}</td>'
f'<td>{r["n_samples"]}</td>'
f'<td class="{unlock_cls}">{unlock_txt}</td>'
f'<td><code>{e0}</code></td>'
f'<td><code>{e5}</code></td>'
f'<td>{rail_txt}</td></tr>')
html.append('</tbody></table>')
html.append('<p>Of the eleven observations, <span class="fail">two '
f'({pct_unlock:.0f}&nbsp;%)</span> registered a PLL unlock at the '
'SN65DSI83 bridge. The unlock pulse widths were '
f'<strong>{unlock_durations[0]:.1f}&nbsp;ms</strong> and '
f'<strong>{unlock_durations[1]:.1f}&nbsp;ms</strong> — slightly '
'longer than the median of the historical unlock dataset '
'(~21&nbsp;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.</p>')
# ── 3. Bursts WITH unlocks ──
html.append('<h2>3. Bursts with detected PLL unlocks</h2>')
html.append('<p>The following two bursts both showed a brief PLL unlock at '
'the SN65 (<code>pll_lock</code> went False momentarily, '
'<code>csr_e5</code> latched 0x01 for one poll cycle). '
'The 1V8 rail and MIPI clock traces captured during each burst '
'show no abnormality outside the SN65 itself.</p>')
for r in results:
if r["n_unlocks"] == 0:
continue
up = r["unlock_pairs"][0]
html.append(f'<h3>3.{r["burst"]} Burst {r["burst"]} — press '
f'{r["press_iso"]}, unlock {up["start_iso"]} '
f'({up["duration_ms"]:.1f}&nbsp;ms)</h3>')
if f"rail_b{r['burst']}" in plots:
html.append(f'<img src="{plots_rel}/{plots[f"rail_b{r["burst"]}"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}" in plots:
html.append('<p><strong>MIPI overview (20 µs window):</strong></p>')
html.append(f'<img src="{plots_rel}/{plots[f"mipi_b{r["burst"]}"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}_zoom_edge" in plots:
html.append('<p><strong>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:</strong></p>')
html.append(f'<img src="{plots_rel}/'
f'{plots[f"mipi_b{r["burst"]}_zoom_edge"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}_zoom_hs" in plots:
html.append('<p><strong>Close-up: HS clock oscillation '
'— 50 ns window showing ~10 individual CLK+ cycles '
'at 216 MHz. Clean square-wave-like alternation '
'with consistent amplitude:</strong></p>')
html.append(f'<img src="{plots_rel}/'
f'{plots[f"mipi_b{r["burst"]}_zoom_hs"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append(f'<p>The rail remained centred on '
f'<strong>{r["rail_mean"]:.1f}&nbsp;mV</strong> with '
f'<strong>{r["rail_vpp"]:.0f}&nbsp;mV</strong> 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'<strong>{r["mipi_vpp_med"]:.0f}&nbsp;mV</strong>).</p>')
# ── 4. Bursts WITHOUT unlocks ──
html.append('<h2>4. Bursts with no detectable SN65 state change</h2>')
html.append(f'<p>The following <strong>{n_no_change} of {n_total}</strong> '
f'operator-confirmed flickers produced <em>no</em> 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.</p>')
html.append('<h3>4.1 1V8 supply rail — representative trace</h3>')
if "rail_typical" in plots:
html.append(f'<img src="{plots_rel}/{plots["rail_typical"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append(f'<p>Across all {n_no_change} no-state-change bursts, the rail mean '
f'was <strong>1.7641.766 V</strong> and Vpp was '
f'<strong>120128 mV</strong> — identical to the unlock-bursts '
f'and to clean baselines from earlier sessions.</p>')
html.append('<h3>4.2 MIPI clock and data signals — representative overlay</h3>')
html.append('<p><strong>Wide overview (20 µs window per segment):</strong></p>')
if "mipi_overlay_clk" in plots:
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_clk"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if "mipi_overlay_dat" in plots:
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_dat"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>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.</p>')
html.append('<h3>4.3 Close-up: LP-11 → HS transition (SoT preamble)</h3>')
if "mipi_typical_zoom_edge" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_zoom_edge"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>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.</p>')
html.append('<h3>4.4 Close-up: individual HS clock cycles</h3>')
if "mipi_typical_zoom_hs" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_zoom_hs"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>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.</p>')
html.append('<h3>4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)</h3>')
if "mipi_typical_eye" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_eye"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>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.</p>')
html.append(f'<p>Across all {n_total} bursts, the CLK+ Vpp distribution is '
f'min 267, median 276286, max 285309&nbsp;mV — no outliers '
f'and no degraded segments at any flicker observation.</p>')
# ── 5. Conclusion ──
html.append('<h2>5. Conclusion (current working hypothesis)</h2>')
html.append('<div class="verdict">')
html.append('<strong class="big">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.</strong><br><br>')
html.append('<strong>MIPI signal integrity</strong> across all '
f'{n_total} operator-confirmed flicker observations is '
'<strong>within nominal envelope and error-free</strong>. '
'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 '
'<em>zero</em> protocol-level errors throughout the test '
'(no SOT-bit, LLP, ECC, LP or CRC error flags raised at '
'any point in any burst).<br><br>')
html.append('<strong>The 1V8 supply rail</strong> shows '
'<strong>no obvious anomalies</strong>. Mean voltage holds '
f'at 1.7641.766&nbsp;V (within 2&nbsp;%) across every burst; '
'ripple Vpp sits in the 120128&nbsp;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.<br><br>')
html.append('On that basis, from the hardware data alone, <strong>it is '
'suspected that the MIPI bus and the 1V8 rail are not the '
'root cause of the fault</strong>. The remaining open '
'question is what is happening <em>inside</em> 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.<br><br>')
html.append('Some PLL unlocks <em>were</em> detected during the test '
f'session ({n_with_unlock} of {n_total} flicker '
'observations). '
'<em>Not every unlock will have been captured</em>, '
'however — the measurement depends on the SN65 register '
'being polled at the exact moment of the (brief, '
'~2035&nbsp;ms) state change, and the polling interval '
'(~20&nbsp;ms) means short events can fall between samples. '
'The recorded unlock count is therefore a lower bound.<br><br>')
html.append('<strong>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 doesnt reach the PLL detector.</strong>')
html.append('</div>')
# Rule-out summary table
html.append('<h3>5.1 Hypotheses assessed by this test</h3>')
html.append('<p>Based on the measurements taken, the following hypotheses '
'are <em>not supported</em> by the data; absence of evidence is '
'not absolute proof of absence, but no signature consistent with '
'these mechanisms was observed.</p>')
html.append('<table><thead><tr><th>Hypothesis</th><th>Assessment</th>'
'<th>Evidence</th></tr></thead><tbody>')
html.append('<tr><td>Flicker caused by 1V8 supply brownout</td>'
'<td class="pass">Not supported</td>'
f'<td>Rail mean voltage consistent across all bursts '
f'(1.7641.766&nbsp;V, within 2&nbsp;%); no DC sag observed '
f'coincident with any flicker</td></tr>')
html.append('<tr><td>Flicker caused by 1V8 supply ripple spike</td>'
'<td class="pass">Not supported</td>'
'<td>Vpp 120128&nbsp;mV consistent across both unlock and '
'no-unlock bursts — no differentiation</td></tr>')
html.append('<tr><td>Flicker caused by MIPI clock signal degradation</td>'
'<td class="pass">Not supported</td>'
'<td>CLK+/DAT0+ Vpp distributions consistent across all 11 '
'bursts; folded-eye overlay shows wide open eye with low jitter; '
'no outlier segments</td></tr>')
html.append('<tr><td>Flicker caused by MIPI protocol errors at SN65 '
'input</td><td class="pass">Not supported</td>'
'<td>Zero 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)</td></tr>')
html.append('<tr><td>Flicker caused by MIPI PLL unlock</td>'
'<td class="warn">Partial support — explains ~18% of cases</td>'
'<td>2 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)</td></tr>')
html.append('</tbody></table>')
# ── 6. Recommended next step ──
html.append('<h2>6. Recommended next steps</h2>')
html.append('<p>From a hardware engineering standpoint the data narrows the '
'remaining candidates for the fault to areas downstream of (or '
'inside) the SN65DSI83 bridge:</p>')
html.append('<ul class="tight">')
html.append('<li><strong>Driver / software configuration of the SN65DSI83.</strong> '
'The bridge has roughly sixty I²C-accessible configuration and '
'status registers covering MIPI input lane mapping, PLL setup, '
'LVDS output formatting, panel timings and error handling. Only '
'two (<code>csr_0a</code> and <code>csr_e5</code>) are exposed by '
'the current device-side HTTP endpoint, so the bulk of the '
'bridge\'s state during a flicker event is not directly '
'observable here. Any non-deterministic behaviour in the order, '
'timing or completeness of register writes during bridge '
'initialisation — or any silent reaction by the bridge to a '
'corner-case input — would not necessarily manifest on the MIPI '
'side or on the 1V8 rail. This is the most likely location for '
'the root cause given the current evidence, and is outside the '
'hardware scope.</li>')
html.append('<li><strong>SN65DSI83 LVDS output drivers and the LVDS '
'differential pairs from bridge to panel.</strong> Probing the '
'LVDS pairs during a flicker session would confirm whether the '
'LVDS signal degrades or drops out coincident with a flicker '
'where the MIPI side stays clean.</li>')
html.append('<li><strong>Panel-side LVDS receiver / TCON.</strong> Less '
'likely given the panel is not changing between bursts, but '
'cannot be excluded without LVDS-side measurements.</li>')
html.append('</ul>')
html.append('<p>The two recommended actions are:</p>')
html.append('<ol class="tight">')
html.append('<li>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 '
'<code>csr_0a</code>/<code>csr_e5</code>) would also give '
'visibility of any runtime drift in those registers.</li>')
html.append('<li>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.</li>')
html.append('</ol>')
# ── Footnote ──
html.append('<div class="footnote">Generated from session '
f'<code>{session_id}</code> by <code>make_flicker_report.py</code> '
f'on {today_iso}. Source data: 11 burst captures with '
f'<code>burst_NNNN_*_pll_samples.json</code>, '
f'<code>burst_NNNN_*_rail.csv</code>, and '
f'<code>burst_NNNN_*_mipi_segNNN_clk/dat.csv</code> files in '
f'<code>{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}</code>.'
'</div>')
html.append('</div></body></html>')
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()

258
rail_watch.py Normal file
View File

@@ -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, 510 ms/div (60120 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><len><data>[\\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()

701
trial_runner.py Normal file
View File

@@ -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 '#<ndigits><nbytes>'
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><len><data>[\\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="<i2")
segs.append(codes.astype(np.float64) * y_inc + y_org)
n = len(segs[0]) if segs else 0
out[label] = {"times": np.arange(n) * x_inc + x_org, "segs": segs}
return out
def save_keysight_segments(segments: dict, out_dir: Path, base: str) -> 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()

455
unlock_capture.py Normal file
View File

@@ -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="<i2")
volts = codes.astype(np.float64) * y_inc + y_org
t = np.arange(len(volts)) * x_inc + x_org
path = out_dir / f"{base}_seg{i:03d}_{label}.csv"
np.savetxt(path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
if label == "clk":
n_written += 1
return n_written
# ---------------------------------------------------------------------------
# Per-event capture handler
# ---------------------------------------------------------------------------
def handle_event(event_label: str, event_num: int, session_dir: Path,
rigol, scope, summary_writer, last_state: dict) -> 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()