Parked for now
625
flicker_burst.py
Normal 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()
|
||||
179
flicker_investigation_report_v2.html
Normal file
BIN
flicker_investigation_report_v2_plots/mipi_burst05.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst05_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst05_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_overlay_clk.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_overlay_dat.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_eye.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
flicker_investigation_report_v2_plots/rail_burst05.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
flicker_investigation_report_v2_plots/rail_burst11.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
flicker_investigation_report_v2_plots/rail_typical.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -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
@@ -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> · '
|
||||
f'Report generated {today_iso} · '
|
||||
f'{n_total} operator-confirmed flicker observations analysed</div>')
|
||||
|
||||
# ── TL;DR ──
|
||||
html.append('<div class="tldr">')
|
||||
html.append(f'<strong>TL;DR</strong> 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 bridge’s '
|
||||
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 & error bits</td><td>HTTP / I2C</td>'
|
||||
'<td>Continuous polling at ~50 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 s window (10 ms/div × 12), 100 mV/div, '
|
||||
'−1.8 V offset, DC coupling, 10× probe</td></tr>')
|
||||
html.append('<tr><td>MIPI CLK+ & DAT0+</td><td>Keysight DSO80204B</td>'
|
||||
'<td>100 segments × 20 µs at 5 GSa/s, LP-edge triggered '
|
||||
'at line rate (~48 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} %)</span> registered a PLL unlock at the '
|
||||
'SN65DSI83 bridge. The unlock pulse widths were '
|
||||
f'<strong>{unlock_durations[0]:.1f} ms</strong> and '
|
||||
f'<strong>{unlock_durations[1]:.1f} ms</strong> — slightly '
|
||||
'longer than the median of the historical unlock dataset '
|
||||
'(~21 ms), which is consistent with these being the events '
|
||||
'most visually salient to the operator. No SOT, LLP, ECC, LP, '
|
||||
'or CRC errors were registered at the SN65 in any burst.</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} 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} mV</strong> with '
|
||||
f'<strong>{r["rail_vpp"]:.0f} 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} 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.764–1.766 V</strong> and Vpp was '
|
||||
f'<strong>120–128 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 276–286, max 285–309 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.764–1.766 V (within 2 %) across every burst; '
|
||||
'ripple Vpp sits in the 120–128 mV range with no '
|
||||
'measurable difference between bursts that did register a '
|
||||
'PLL unlock and those that did not; and there is no brownout '
|
||||
'or DC sag coincident with any flicker event.<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, '
|
||||
'~20–35 ms) state change, and the polling interval '
|
||||
'(~20 ms) means short events can fall between samples. '
|
||||
'The recorded unlock count is therefore a lower bound.<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 doesn’t 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.764–1.766 V, within 2 %); 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 120–128 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
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rail_watch.py — Capture Rigol DS1202Z-E CH1 (1V8 supply rail) every time the
|
||||
SN65DSI83 reports a MIPI PLL unlock.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions.
|
||||
- On each unlock, :STOPs the Rigol, reads CH1 waveform via :WAV:DATA?, saves
|
||||
to CSV in data/rail_traces/, prints peak-to-peak ripple, then :RUNs again.
|
||||
- Press `g` to capture a baseline (clean) trace. Press `q` to quit.
|
||||
|
||||
Rigol setup (do once on the front panel before running):
|
||||
* Channel 1 probed on the 1V8 rail derived to the MIPI PHY
|
||||
* DC coupling with offset, or AC coupling for ripple-only view
|
||||
* Recommended: 20 mV/div, 5–10 ms/div (60–120 ms window)
|
||||
* Trigger: AUTO on Channel 1 so the buffer is always recent
|
||||
* Memory depth: 12M (or whatever fits the timebase)
|
||||
* :RUN the scope so it's continuously acquiring
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import vxi11
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
DEVICE_BASE = "http://192.168.45.8:5000"
|
||||
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
|
||||
RIGOL_IP = "192.168.45.5"
|
||||
DATA_DIR = Path(__file__).parent / "data" / "rail_traces"
|
||||
|
||||
POLL_DT_S = 0.020 # 50 Hz target — coarser than sn65_monitor
|
||||
HTTP_TO_S = 0.2
|
||||
RIGOL_TO_S = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rigol I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
def _read_ieee_block(rigol) -> bytes:
|
||||
"""Read an IEEE 488.2 binary block from the scope: '#'<n><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
@@ -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
@@ -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()
|
||||