Compare commits

...

9 Commits

Author SHA1 Message Date
David Rice
2c4400914f Commit 2026-05-26 17:33:37 +02:00
David Rice
0f7b0e1ac5 Commit 2026-05-26 17:33:02 +02:00
David Rice
423766f7a3 Commit 2026-05-26 08:06:49 +02:00
david rice
39f4355b8d Parked for now 2026-05-15 16:32:15 +01:00
david rice
d73aa2f2a4 Changes 2026-05-11 16:14:19 +01:00
david rice
8d8df1e7a7 Updates 2026-05-11 08:21:34 +01:00
david rice
75248c9574 updates 2026-05-07 12:10:02 +01:00
david rice
dd93fbd893 Updates 2026-05-07 09:01:32 +01:00
david rice
9c75598728 Updated 2026-04-28 16:38:44 +01:00
44 changed files with 7430 additions and 160 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(python *)",
"Bash(pip list *)",
"Read(//c/Users/DavidRice/AppData/Local/Programs/Python/Python312/Scripts/**)",
"Bash(py -3.11 -c \"import matplotlib; print\\(matplotlib.__version__\\)\")",
"Bash(py -3.10 -c \"import matplotlib; print\\(matplotlib.__version__\\)\")",
"Bash(pip install *)",
"Bash(\"C:/Users/DavidRice/AppData/Local/Programs/Python/Python312/python.exe\" -m pip install matplotlib)"
]
}
}

BIN
.gitignore vendored Normal file

Binary file not shown.

Binary file not shown.

183
analyze_session.py Normal file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
analyze_session.py — Cross-reference sn65_monitor unlocks against
video_cycler events to determine whether unlocks are caused by video
START transitions, STOP transitions, or both.
Usage:
python3 analyze_session.py # use latest sn65 + latest cycle log
python3 analyze_session.py <sn65.json> # specify sn65 log
python3 analyze_session.py <sn65.json> <cycle.csv>
"""
from __future__ import annotations
import csv
import json
import statistics
import sys
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
SN65_DIR = DATA_DIR / "sn65_log"
CYCLE_DIR = DATA_DIR / "cycle_logs"
def find_latest(dir_path: Path, glob: str) -> Path | None:
files = sorted(dir_path.glob(glob))
return files[-1] if files else None
def load_unlocks(sn65_path: Path) -> list[dict]:
"""Pair pll_lock False→True transitions and return [{start_ts, end_ts, duration_ms}, ...]."""
data = json.loads(sn65_path.read_text())
changes = sorted(data.get("session_changes", []), key=lambda c: c["ts"])
pll = [c for c in changes if "pll_lock" in c.get("delta", {})]
unlocks: list[dict] = []
i = 0
while i < len(pll):
e = pll[i]
_, new = e["delta"]["pll_lock"]
if new is False:
for j in range(i + 1, min(i + 20, len(pll))):
_, n_new = pll[j]["delta"]["pll_lock"]
if n_new is True:
unlocks.append({
"start_ts": e["ts"],
"end_ts": pll[j]["ts"],
"duration_ms": (pll[j]["ts"] - e["ts"]) * 1000,
"iso": e["iso"],
})
i = j
break
i += 1
return unlocks
def load_cycle_events(cycle_path: Path) -> list[dict]:
"""Load timestamped video start/stop events from a cycle log CSV."""
out: list[dict] = []
with open(cycle_path) as f:
for row in csv.DictReader(f):
out.append({
"ts": float(row["unix_ts"]),
"event": row["event"],
"cycle": int(row["cycle"]),
"iso": row["iso"],
})
return out
def nearest_event(unlock_ts: float, events: list[dict],
event_type: str | None = None) -> dict | None:
candidates = events if event_type is None else [e for e in events if e["event"] == event_type]
if not candidates:
return None
return min(candidates, key=lambda e: abs(e["ts"] - unlock_ts))
def main() -> None:
args = sys.argv[1:]
sn65_path = Path(args[0]) if len(args) >= 1 else find_latest(SN65_DIR, "*.json")
cycle_path = Path(args[1]) if len(args) >= 2 else find_latest(CYCLE_DIR, "*.csv")
if sn65_path is None or not sn65_path.exists():
print(f"No sn65 log found.")
sys.exit(1)
print(f"sn65 log: {sn65_path}")
print(f"cycle log: {cycle_path if cycle_path else '(none — START/STOP correlation unavailable)'}")
# === Pulse-width analysis ===
unlocks = load_unlocks(sn65_path)
print(f"\n=== PLL unlocks ===")
print(f" total: {len(unlocks)}")
if not unlocks:
print(" no unlocks — nothing more to analyse")
return
durs = sorted(u["duration_ms"] for u in unlocks)
n = len(durs)
print(f" pulse-width: min={durs[0]:.1f} ms med={durs[n//2]:.1f} ms"
f" p90={durs[min(n*9//10, n-1)]:.1f} ms max={durs[-1]:.1f} ms")
print(f" per-event:")
for u in unlocks:
print(f" {u['iso']} duration {u['duration_ms']:6.1f} ms")
# === Correlation against cycle events ===
if cycle_path is None or not cycle_path.exists():
return
events = load_cycle_events(cycle_path)
n_start = sum(1 for e in events if e["event"] == "start")
n_stop = sum(1 for e in events if e["event"] == "stop")
print(f"\n=== Cycle events ===")
print(f" {len(events)} events total ({n_start} start, {n_stop} stop)")
if events:
print(f" first: {events[0]['iso']} last: {events[-1]['iso']}")
# For each unlock find offsets to nearest start and nearest stop
print(f"\n=== Unlock vs. nearest cycle event ===")
print(f" {'unlock iso':<25} {'dur_ms':>7} "
f"{'Δ_to_START':>12} {'Δ_to_STOP':>12} verdict")
near_start = [] # offsets (s) from nearest START to each unlock
near_stop = []
starts = [e for e in events if e["event"] == "start"]
stops = [e for e in events if e["event"] == "stop"]
classifications = {"start": 0, "stop": 0, "neither": 0}
for u in unlocks:
ns = nearest_event(u["start_ts"], starts)
nt = nearest_event(u["start_ts"], stops)
d_start = (u["start_ts"] - ns["ts"]) if ns else None
d_stop = (u["start_ts"] - nt["ts"]) if nt else None
# Classify: "after start" if 0..3s after a start, "after stop" if 0..3s after a stop.
verdict = "neither"
if d_start is not None and 0.0 <= d_start <= 3.0 \
and (d_stop is None or d_stop < 0 or d_stop > d_start):
verdict = "after_START"
near_start.append(d_start)
classifications["start"] += 1
elif d_stop is not None and 0.0 <= d_stop <= 3.0:
verdict = "after_STOP"
near_stop.append(d_stop)
classifications["stop"] += 1
else:
classifications["neither"] += 1
def fmt(v):
if v is None: return ""
return f"{v:+8.2f}s"
print(f" {u['iso']:<25} {u['duration_ms']:>7.1f} "
f"{fmt(d_start):>12} {fmt(d_stop):>12} {verdict}")
total = sum(classifications.values())
print(f"\n=== Verdict ===")
if total:
print(f" after START : {classifications['start']:>3} / {total} "
f"({classifications['start']/total*100:.0f}%)")
print(f" after STOP : {classifications['stop']:>3} / {total} "
f"({classifications['stop']/total*100:.0f}%)")
print(f" neither : {classifications['neither']:>3} / {total} "
f"({classifications['neither']/total*100:.0f}%)")
if near_start:
print(f"\n offsets after START (n={len(near_start)}): "
f"med={statistics.median(near_start)*1000:.0f} ms, "
f"min={min(near_start)*1000:.0f} ms, max={max(near_start)*1000:.0f} ms")
if near_stop:
print(f" offsets after STOP (n={len(near_stop)}): "
f"med={statistics.median(near_stop)*1000:.0f} ms, "
f"min={min(near_stop)*1000:.0f} ms, max={max(near_stop)*1000:.0f} ms")
# Per-cycle unlock rate
if events:
cycle_window_s = events[-1]["ts"] - events[0]["ts"]
n_cycles = max(1, max(e["cycle"] for e in events))
print(f"\n cycles seen: {n_cycles} total span: {cycle_window_s:.1f}s")
print(f" unlocks per cycle: {len(unlocks)/n_cycles:.2f}")
print(f" unlocks per minute: {len(unlocks)/cycle_window_s*60:.2f}")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

321
compare_stops.py Normal file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
compare_stops.py — Compare different video-stop styles for SN65 PLL impact.
For each "stop style" defined in STYLES, this script:
1. Polls /sn65_registers in a background thread (50 Hz, like sn65_monitor)
2. Runs N cycles of start → on_s seconds → stop → off_s seconds
3. Records PLL unlock events and which style was active when each fired
At the end it prints a comparison table: per-style cycles run, unlocks seen,
unlocks-per-stop probability, and unlock pulse-width stats. This is the
test you use to verify that switching the device's "stop" semantics (e.g.
GStreamer PAUSE instead of NULL) actually reduces the unlock rate.
Edit STYLES at the top of the file to add new stop variants as your
device server gains them. The start payload can vary too if you want
to test different start sequences.
Usage:
python3 compare_stops.py # 20 cycles per style
python3 compare_stops.py --cycles 50 --on-s 5
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
import requests
DEVICE_BASE = "http://192.168.45.8:5000"
VIDEO_URL = f"{DEVICE_BASE}/video"
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
POLL_DT_S = 0.02 # 50 Hz target
HTTP_TIMEOUT_S = 0.2
LOG_DIR = Path(__file__).parent / "data" / "compare_logs"
# -------------------------------------------------------------------------
# STYLES — edit this list to add new stop variants
# -------------------------------------------------------------------------
# Each entry: (label, start_payload_dict, stop_payload_dict)
# The default first entry is the current /video stop behaviour.
# When your device server supports alternative actions (e.g. "pause"),
# uncomment / add the corresponding line below.
STYLES: list[tuple[str, dict, dict]] = [
("stop_full",
{"action": "start", "mode": "static-pink"},
{"action": "stop"}),
# === Add styles below once the device server supports them ===
# ("stop_pause", # GST_STATE_PAUSED — keeps DSI clock alive
# {"action": "start", "mode": "static-pink"},
# {"action": "pause"}),
# ("stop_ready", # GST_STATE_READY — half-way teardown
# {"action": "start", "mode": "static-pink"},
# {"action": "ready"}),
# ("stop_keep_clk",
# {"action": "start", "mode": "static-pink"},
# {"action": "stop", "keep_clock": True}),
]
# -------------------------------------------------------------------------
# SN65 polling thread
# -------------------------------------------------------------------------
class SN65Poller:
"""Background thread polling /sn65_registers; records every PLL transition."""
ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
def __init__(self) -> None:
self.sess = requests.Session()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self.changes: list[dict] = []
self.samples: list[dict] = [] # every poll: {ts, state, err?}
self._last_state: dict | None = None
self.actual_hz = 0.0
self.err_count = 0
def start(self) -> None:
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
def _run(self) -> None:
n_polls = 0
rate_t0 = time.time()
while not self._stop.is_set():
t0 = time.time()
try:
r = self.sess.get(SN65_EP, timeout=HTTP_TIMEOUT_S)
r.raise_for_status()
data = r.json()
except requests.exceptions.RequestException:
self.samples.append({"ts": t0, "error": True})
self.err_count += 1
if t0 - rate_t0 > 2.0:
self.actual_hz = n_polls / (t0 - rate_t0); rate_t0 = t0; n_polls = 0
time.sleep(POLL_DT_S)
continue
regs = data.get("registers", {})
csr0a = regs.get("csr_0a", {}) or {}
csre5 = regs.get("csr_e5", {}) or {}
state = {
"csr_0a": csr0a.get("value"),
"csr_e5": csre5.get("value"),
"pll_lock": csr0a.get("pll_lock"),
"clk_det": csr0a.get("clk_det"),
}
for k in self.ERROR_BITS:
state[k] = csre5.get(k)
self.samples.append({"ts": t0, "state": state})
n_polls += 1
if self._last_state is not None and state != self._last_state:
delta = {k: (self._last_state.get(k), state.get(k))
for k in state if state.get(k) != self._last_state.get(k)}
self.changes.append({"ts": t0, "delta": delta, "new_state": state})
self._last_state = state
if t0 - rate_t0 > 2.0:
self.actual_hz = n_polls / (t0 - rate_t0); rate_t0 = t0; n_polls = 0
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
def unlocks(self) -> list[dict]:
"""Pair pll_lock False→True transitions into [{start_ts, end_ts, duration_ms}]."""
pll = [c for c in self.changes if "pll_lock" in c["delta"]]
out, i = [], 0
while i < len(pll):
_, new = pll[i]["delta"]["pll_lock"]
if new is False:
for j in range(i+1, min(i+20, len(pll))):
_, n_new = pll[j]["delta"]["pll_lock"]
if n_new is True:
out.append({"start_ts": pll[i]["ts"],
"end_ts": pll[j]["ts"],
"duration_ms": (pll[j]["ts"]-pll[i]["ts"])*1000})
i = j
break
i += 1
return out
# -------------------------------------------------------------------------
# Cycle runner
# -------------------------------------------------------------------------
def put_video(payload: dict, label: str) -> None:
try:
requests.put(VIDEO_URL, json=payload, timeout=3.0)
except requests.exceptions.RequestException as e:
print(f" {label} HTTP failed: {e}")
def run_style(label: str, start_payload: dict, stop_payload: dict,
n_cycles: int, on_s: float, off_s: float,
poller: SN65Poller, transitions: list) -> tuple[float, float]:
"""Run n cycles of the given style. Returns (start_ts, end_ts)."""
print(f"\n▸ style '{label}'")
print(f" start={start_payload} stop={stop_payload} cycles={n_cycles}")
style_start = time.time()
for c in range(1, n_cycles + 1):
t = time.time()
put_video(start_payload, "start")
transitions.append({"ts": t, "event": "start", "style": label, "cycle": c})
time.sleep(on_s)
t = time.time()
put_video(stop_payload, "stop")
transitions.append({"ts": t, "event": "stop", "style": label, "cycle": c})
if c % 5 == 0 or c == n_cycles:
n_unlocks = len(poller.unlocks())
print(f" cycle {c:>3}/{n_cycles} "
f"{poller.actual_hz:5.1f} Hz "
f"unlocks so far: {n_unlocks}", flush=True)
time.sleep(off_s)
return style_start, time.time()
# -------------------------------------------------------------------------
# Analysis
# -------------------------------------------------------------------------
def analyse(styles_run: list[dict], unlocks: list[dict],
transitions: list[dict]) -> None:
"""Bucket unlocks by which style was active when they fired."""
print("\n" + "="*78)
print(" STOP-STYLE COMPARISON")
print("="*78)
print(f"\n {'style':<18} {'cycles':>6} {'unlocks':>8} "
f"{'per_cycle':>10} {'med_pulse':>10} {'min_pulse':>10} {'max_pulse':>10}")
print(f" {'-'*18} {'-'*6} {'-'*8} {'-'*10} {'-'*10} {'-'*10} {'-'*10}")
for s in styles_run:
in_window = [u for u in unlocks
if s["t_start"] <= u["start_ts"] <= s["t_end"]]
durs = [u["duration_ms"] for u in in_window]
n = len(in_window)
per_cycle = n / s["cycles"]
med = statistics.median(durs) if durs else 0.0
mn = min(durs) if durs else 0.0
mx = max(durs) if durs else 0.0
print(f" {s['label']:<18} {s['cycles']:>6} {n:>8} "
f"{per_cycle:>10.2f} "
f"{med:>8.1f}ms {mn:>8.1f}ms {mx:>8.1f}ms")
# also: distribution after STOP (we expect 100% if behaviour is same as baseline)
stops_in_style = [t for t in transitions
if t["style"] == s["label"] and t["event"] == "stop"]
after_stop = 0
after_start = 0
for u in in_window:
nearest_stop = min((t["ts"] for t in stops_in_style),
key=lambda ts: abs(ts - u["start_ts"]),
default=None)
starts_in_style = [t for t in transitions
if t["style"] == s["label"] and t["event"] == "start"]
nearest_start = min((t["ts"] for t in starts_in_style),
key=lambda ts: abs(ts - u["start_ts"]),
default=None)
if nearest_stop is None or nearest_start is None:
continue
d_stop = u["start_ts"] - nearest_stop
d_start = u["start_ts"] - nearest_start
if 0 <= d_stop <= 3.0 and (d_start < 0 or d_stop < d_start):
after_stop += 1
elif 0 <= d_start <= 3.0:
after_start += 1
if n:
print(f" {'':<18} ↳ after STOP: {after_stop}, after START: {after_start},"
f" other: {n - after_stop - after_start}")
def save_log(styles_run, unlocks, transitions, samples) -> Path:
LOG_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out = LOG_DIR / f"{ts}_compare.json"
out.write_text(json.dumps({
"saved_at": ts,
"styles": styles_run,
"unlocks": unlocks,
"transitions": transitions,
"n_samples": len(samples),
}, indent=2, default=str))
return out
def main() -> None:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cycles", type=int, default=20,
help="number of cycles per style (default 20)")
ap.add_argument("--on-s", type=float, default=10.0,
help="seconds video is ON per cycle (default 10)")
ap.add_argument("--off-s", type=float, default=0.5,
help="seconds video is OFF per cycle (default 0.5)")
ap.add_argument("--settle-s", type=float, default=2.0,
help="seconds to wait between styles (default 2)")
args = ap.parse_args()
if not STYLES:
print("No styles configured — edit STYLES at the top of this file.")
sys.exit(1)
print(f"compare_stops — {len(STYLES)} style(s) × {args.cycles} cycles "
f"({args.on_s}s on / {args.off_s}s off)")
print(f" styles: {[s[0] for s in STYLES]}")
poller = SN65Poller()
poller.start()
print(f" SN65 polling started — waiting 1s for first sample...")
time.sleep(1.0)
styles_run = []
transitions: list = []
try:
for label, start_p, stop_p in STYLES:
t0, t1 = run_style(label, start_p, stop_p,
args.cycles, args.on_s, args.off_s,
poller, transitions)
styles_run.append({"label": label, "cycles": args.cycles,
"t_start": t0, "t_end": t1,
"start_payload": start_p,
"stop_payload": stop_p})
# Settle so unlocks from one style don't get attributed to the next
print(f" settling {args.settle_s}s ...")
time.sleep(args.settle_s)
except KeyboardInterrupt:
print("\nInterrupted — leaving video stopped")
put_video({"action": "stop"}, "stop-on-exit")
poller.stop()
unlocks = poller.unlocks()
print(f"\n SN65 poller: actual ~{poller.actual_hz:.1f} Hz, "
f"{poller.err_count} HTTP errors, "
f"{len(unlocks)} total PLL unlock(s)")
analyse(styles_run, unlocks, transitions)
out = save_log(styles_run, unlocks, transitions, poller.samples)
print(f"\n log saved → {out.relative_to(LOG_DIR.parent.parent)}")
if __name__ == "__main__":
main()

View File

@@ -892,12 +892,27 @@ def analyze_lp_file(path: Path) -> "LPMetrics":
HS : voltage in mid-range with high oscillation (rolling std > HS_OSC_STD_V) HS : voltage in mid-range with high oscillation (rolling std > HS_OSC_STD_V)
trans : everything else (transitions between states) trans : everything else (transitions between states)
""" """
m = re.match(r"(\d{8}_\d{6})_lp_(\d+)_(clk|dat)\.csv", path.name, re.IGNORECASE) # Accept three filename formats:
# legacy: "_lp_0001_"
# watch: "_lp_c001_01_"
# segmented: "_lp_c001_01_seg005_" (one segment exploded from H5)
m = re.match(
r"(\d{8}_\d{6})_lp_(c\d+_\d+(?:_seg\d+)?|\d+)_(clk|dat)\.csv",
path.name, re.IGNORECASE,
)
if not m: if not m:
raise ValueError(f"Filename does not match lp pattern: {path.name}") raise ValueError(f"Filename does not match lp pattern: {path.name}")
timestamp, cap_str, channel = m.groups() timestamp, cap_str, channel = m.groups()
capture_num = int(cap_str) # Derive an int capture_num from whatever digits the id contains, so it
# remains sortable (e.g., c001_01_seg005 → 1*1_000_000 + 1*1_000 + 5).
digit_groups = re.findall(r"\d+", cap_str)
if len(digit_groups) == 1:
capture_num = int(digit_groups[0])
else:
capture_num = 0
for i, d in enumerate(reversed(digit_groups)):
capture_num += int(d) * (1000 ** i)
times, volts = _read_csv(path) times, volts = _read_csv(path)
dt = float(np.diff(times).mean()) dt = float(np.diff(times).mean())

14
cycle.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Blank/unblank cycle reproducer for the MIPI flicker investigation.
# Each iteration: blank the display, wait 1s, unblank, wait 3s.
# Watch the screen and count cycles that produced visible flicker.
N=${1:-30}
for i in $(seq 1 $N); do
echo "--- cycle $i / $N ---"
echo 4 > /sys/class/graphics/fb0/blank
sleep 1
echo 0 > /sys/class/graphics/fb0/blank
sleep 3
done
echo "done."

16
device-server.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=MiPi device HTTP control server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root/python
ExecStart=/usr/bin/python3 /root/python/device_server.py
Restart=on-failure
RestartSec=2
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -8,9 +8,11 @@ Provides:
Add addresses to REGISTER_COMMANDS to capture more register ranges. Add addresses to REGISTER_COMMANDS to capture more register ranges.
""" """
import mmap
import os import os
import re import re
import socket import socket
import struct
import subprocess import subprocess
import threading import threading
import time import time
@@ -22,7 +24,7 @@ app = Flask(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Video playback state (managed as a subprocess) # Video playback state (managed as a subprocess)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
KIOSK_SCRIPT = "/root/display_test_nexio.py" KIOSK_SCRIPT = "/root/python/display_test_nexio.py"
_video_proc: subprocess.Popen | None = None _video_proc: subprocess.Popen | None = None
_video_lock = threading.Lock() _video_lock = threading.Lock()
@@ -82,8 +84,8 @@ _SN65_SNAPSHOT_REGS: dict[int, str] = {
0x2A: "HFP", # CHA horizontal front porch 0x2A: "HFP", # CHA horizontal front porch
0x2C: "VFP", # CHA vertical front porch 0x2C: "VFP", # CHA vertical front porch
# Format / output # Format / output
0x2D: "TEST_PATTERN", # bit0 = enable colour bar test pattern 0x2D: "REG_0x2D", # unknown — was mislabeled "TEST_PATTERN" but isn't
0x3C: "LVDS_FORMAT", # LVDS output format (colour depth, channel swap) 0x3C: "LVDS_FORMAT", # LVDS output format. bit 4 = CHA_TEST_PATTERN (write 0x10 to enable)
# Live LVDS line counter — changes every frame when bridge is actively outputting # Live LVDS line counter — changes every frame when bridge is actively outputting
0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live] 0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live]
0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live] 0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live]
@@ -144,6 +146,13 @@ _DSIM_NAMES = {
0x32e1000c: "DSIM_TIMEOUT", 0x32e1000c: "DSIM_TIMEOUT",
0x32e10010: "DSIM_CONFIG", 0x32e10010: "DSIM_CONFIG",
0x32e10014: "DSIM_ESCMODE", 0x32e10014: "DSIM_ESCMODE",
0x32e10018: "DSIM_MDRESOL",
0x32e1001c: "DSIM_MVPORCH",
0x32e10020: "DSIM_MHPORCH",
0x32e10024: "DSIM_MSYNC",
0x32e10028: "DSIM_SDRESOL",
0x32e1002c: "DSIM_INTSRC", # interrupt source — bits latch on event, write-1-clear
0x32e10030: "DSIM_INTMSK", # interrupt mask config
0x32e100ac: "DSIM_PHYACCHR", 0x32e100ac: "DSIM_PHYACCHR",
0x32e100b0: "DSIM_PHYACCHR1", 0x32e100b0: "DSIM_PHYACCHR1",
0x32e100b4: "DSIM_PHYTIMING", 0x32e100b4: "DSIM_PHYTIMING",
@@ -280,6 +289,71 @@ def _i2c_read_byte(bus: int, addr: int, reg: int) -> tuple[int | None, str]:
return None, str(e) return None, str(e)
def _i2c_write_byte(bus: int, addr: int, reg: int, val: int) -> tuple[bool, str]:
"""Write one byte via i2cset. Returns (ok, error_str)."""
try:
result = subprocess.run(
["i2cset", "-y", "-f", str(bus), f"0x{addr:02x}",
f"0x{reg:02x}", f"0x{val:02x}"],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
return True, ""
return False, result.stderr.strip() or f"exit code {result.returncode}"
except FileNotFoundError:
return False, "i2cset not found in PATH"
except Exception as e:
return False, str(e)
def _read_memtool_words(base_addr: int, n_words: int) -> list:
"""Read n 32-bit words via 'memtool md -l'. Returns list of (addr, value)."""
try:
cmd = ["memtool", "md", "-l", f"0x{base_addr:08x}+0x{n_words*4:x}"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
if result.returncode != 0:
return []
return [(int(r["address"], 16), int(r["value"], 16))
for r in _parse_memtool_output(result.stdout)]
except Exception:
return []
# DSIM register blocks worth watching. Two contiguous ranges → 2 memtool calls per snapshot.
# Block 1: status / config / timing / interrupts (0x004-0x030)
# STATUS, CLKCTRL, TIMEOUT, CONFIG, ESCMODE, MDRESOL, MVPORCH, MHPORCH,
# MSYNC, SDRESOL, INTSRC, INTMSK
# Block 2: PHY (0xAC-0xBC)
# PHYACCHR, PHYACCHR1, PHYTIMING, PHYTIMING1, PHYTIMING2
_DSIM_SNAPSHOT_BLOCKS = [
(0x32e10004, 12),
(0x32e100ac, 5),
]
def _dsim_snapshot() -> dict:
"""Read DSIM status/config/PHY registers via memtool.
Returns {address_hex: {name, value}} or value=None on read failure."""
snapshot = {}
for base, n in _DSIM_SNAPSHOT_BLOCKS:
words = _read_memtool_words(base, n)
# If read failed entirely, log Nones for each expected address so a diff still surfaces
if not words:
for i in range(n):
addr = base + i * 4
snapshot[f"0x{addr:08x}"] = {
"name": _DSIM_NAMES.get(addr, ""),
"value": None,
}
continue
for addr, val in words:
snapshot[f"0x{addr:08x}"] = {
"name": _DSIM_NAMES.get(addr, ""),
"value": f"0x{val:08x}",
}
return snapshot
@app.route("/sn65_settling", methods=["GET"]) @app.route("/sn65_settling", methods=["GET"])
def get_sn65_settling(): def get_sn65_settling():
"""Return the most recent post-restart settling poll. """Return the most recent post-restart settling poll.
@@ -356,6 +430,385 @@ def get_sn65_registers():
}), 200 }), 200
# ---------------------------------------------------------------------------
# High-rate PLL monitor — runs on-device so we sample at ~10 ms instead of
# the ~55 ms HTTP-polling could achieve. Logs only transitions (unlock /
# recovered) so the event log stays small.
# ---------------------------------------------------------------------------
PLL_MONITOR_DEFAULT_MS = 10
PLL_MONITOR_MAX_EVENTS = 10000
_pll_monitor_thread: threading.Thread | None = None
_pll_wide_thread: threading.Thread | None = None
_dsim_fast_thread: threading.Thread | None = None
_pll_monitor_stop: threading.Event = threading.Event()
_pll_monitor_lock: threading.Lock = threading.Lock()
_pll_monitor_events: list = []
_pll_monitor_stats: dict = {
"running": False, "interval_ms": 0, "wide_interval_ms": 0,
"fast_dsim_interval_ms": 0, "fast_dsim_polls": 0, "fast_dsim_error": None,
"polls": 0, "errors": 0, "wide_polls": 0, "started_at": None,
}
# ---------------------------------------------------------------------------
# Fast DSIM register poller via /dev/mem mmap. Bypasses the memtool subprocess
# overhead so we can poll at sub-millisecond resolution and catch transient
# register changes that the 500 ms wide loop would miss.
# ---------------------------------------------------------------------------
DSIM_BASE = 0x32E10000
DSIM_PAGE_SIZE = 0x1000 # one 4 KB page covers offsets 0x000-0xFFF
# Register offsets within the DSIM page that we want to watch.
_DSIM_FAST_REGS = {
0x004: "DSIM_STATUS", # flutters with frame counter — excluded from diff
0x008: "DSIM_CLKCTRL",
0x00c: "DSIM_TIMEOUT",
0x010: "DSIM_CONFIG",
0x014: "DSIM_ESCMODE",
0x018: "DSIM_MDRESOL",
0x01c: "DSIM_MVPORCH",
0x020: "DSIM_MHPORCH",
0x024: "DSIM_MSYNC",
0x028: "DSIM_SDRESOL",
0x02c: "DSIM_INTSRC",
0x030: "DSIM_INTMSK",
0x0ac: "DSIM_PHYACCHR",
0x0b0: "DSIM_PHYACCHR1",
0x0b4: "DSIM_PHYTIMING",
0x0b8: "DSIM_PHYTIMING1",
0x0bc: "DSIM_PHYTIMING2",
}
# DSIM_STATUS flutters every frame so a naive diff drowns out everything else.
# Exclude it from the diff. (Reading it still happens — the value is captured
# in the event payload for any iteration that flagged a change in another reg.)
_DSIM_FAST_SKIP = {0x004}
# Registers that change every frame — exclude from diff so we don't drown in noise.
# SN65 uses short hex (e.g. "0xe0"); DSIM uses long hex (e.g. "0x32e10004") so no collisions.
_PLL_WIDE_LIVE_REGS = {
"0xe0", "0xe1", # SN65 LINE_CNT_LOW / LINE_CNT_HIGH
# DSIM_STATUS bits 0-3 are FRAME_DONE/BUSY/TX_READY-style flags that fluctuate
# every frame, so the whole register is treated as live by default. Comment out
# if you want to see all its changes (and accept the noise).
"0x32e10004", # DSIM_STATUS
}
def _classify_sn65(val_0a: int, val_e5: int) -> tuple[bool, list]:
"""Returns (any_error, flag_list). pll_lock=False is included as a flag."""
pll_locked = bool(val_0a & 0x80)
flags = []
if not pll_locked: flags.append("pll_lock_false")
if val_e5 & 0x01: flags.append("pll_unlock")
if val_e5 & 0x04: flags.append("cha_sot_bit_err")
if val_e5 & 0x08: flags.append("cha_llp_err")
if val_e5 & 0x10: flags.append("cha_ecc_err")
if val_e5 & 0x20: flags.append("cha_lp_err")
if val_e5 & 0x40: flags.append("cha_crc_err")
return bool(flags), flags
def _pll_monitor_loop(interval_ms: int) -> None:
interval_s = interval_ms / 1000.0
last_bad = False
with _pll_monitor_lock:
_pll_monitor_stats.update(running=True, interval_ms=interval_ms,
polls=0, errors=0, started_at=time.time())
_pll_monitor_events.clear()
while not _pll_monitor_stop.is_set():
t0 = time.time()
val_0a, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A)
val_e5, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5)
with _pll_monitor_lock:
_pll_monitor_stats["polls"] += 1
if val_0a is None or val_e5 is None:
_pll_monitor_stats["errors"] += 1
else:
bad, flags = _classify_sn65(val_0a, val_e5)
if bad and not last_bad:
_pll_monitor_events.append({
"t": t0,
"type": "unlock",
"csr_0a": f"0x{val_0a:02x}",
"csr_e5": f"0x{val_e5:02x}",
"flags": flags,
})
elif (not bad) and last_bad:
_pll_monitor_events.append({
"t": t0,
"type": "recovered",
"csr_0a": f"0x{val_0a:02x}",
"csr_e5": f"0x{val_e5:02x}",
})
last_bad = bad
if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]
elapsed = time.time() - t0
if elapsed < interval_s:
time.sleep(interval_s - elapsed)
with _pll_monitor_lock:
_pll_monitor_stats["running"] = False
def _pll_wide_loop(interval_ms: int) -> None:
"""Background thread: snapshot all SN65 config/status registers AND the
DSIM status/PHY register block at interval_ms cadence, log a single
'register_change' event per poll containing any diffs vs previous snapshot.
Excludes registers in _PLL_WIDE_LIVE_REGS from the diff."""
interval_s = interval_ms / 1000.0
def _combined_snapshot() -> dict:
snap = _sn65_snapshot()
snap.update(_dsim_snapshot())
return snap
prev = _combined_snapshot() # baseline
with _pll_monitor_lock:
_pll_monitor_stats["wide_interval_ms"] = interval_ms
_pll_monitor_stats["wide_polls"] = 0
while not _pll_monitor_stop.is_set():
t0 = time.time()
cur = _combined_snapshot()
changes = {}
for reg, info in cur.items():
if reg in _PLL_WIDE_LIVE_REGS:
continue
prev_info = prev.get(reg, {})
if info.get("value") != prev_info.get("value"):
changes[reg] = {
"name": info.get("name"),
"from": prev_info.get("value"),
"to": info.get("value"),
}
with _pll_monitor_lock:
_pll_monitor_stats["wide_polls"] += 1
if changes:
_pll_monitor_events.append({
"t": t0,
"type": "register_change",
"changes": changes,
})
if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]
prev = cur
elapsed = time.time() - t0
if elapsed < interval_s:
time.sleep(interval_s - elapsed)
def _dsim_fast_loop(interval_ms: int) -> None:
"""High-rate DSIM register poller using /dev/mem mmap.
Reads ONLY DSIM_CLKCTRL each iteration (the register known to flip
sub-millisecond). Other registers are left to the 500 ms wide loop —
this keeps the fast loop's CPU cost low enough not to starve Flask.
Logs every CLKCTRL transition as a 'dsim_fast_change' event."""
interval_s = max(interval_ms, 1) / 1000.0
try:
fd = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
except OSError as e:
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_error"] = f"open /dev/mem failed: {e}"
return
try:
mm = mmap.mmap(fd, DSIM_PAGE_SIZE, mmap.MAP_SHARED, mmap.PROT_READ,
offset=DSIM_BASE)
except (OSError, ValueError) as e:
os.close(fd)
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_error"] = f"mmap DSIM page failed: {e}"
return
CLKCTRL_OFFSET = 0x008
CLKCTRL_ADDR = f"0x{DSIM_BASE + CLKCTRL_OFFSET:08x}"
prev = struct.unpack_from("<I", mm, CLKCTRL_OFFSET)[0]
polls = 0
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_interval_ms"] = interval_ms
_pll_monitor_stats["fast_dsim_polls"] = 0
_pll_monitor_stats["fast_dsim_error"] = None
try:
while not _pll_monitor_stop.is_set():
try:
t0 = time.time()
polls += 1
cur = struct.unpack_from("<I", mm, CLKCTRL_OFFSET)[0]
if cur != prev:
with _pll_monitor_lock:
_pll_monitor_events.append({
"t": t0,
"type": "dsim_fast_change",
"changes": {
CLKCTRL_ADDR: {
"name": "DSIM_CLKCTRL",
"from": f"0x{prev:08x}",
"to": f"0x{cur:08x}",
}
},
})
if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]
prev = cur
# Push polls counter to stats periodically
if polls % 1000 == 0:
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_polls"] = polls
elapsed = time.time() - t0
if elapsed < interval_s:
time.sleep(interval_s - elapsed)
except Exception as e:
# Don't die silently; surface the error as an event and keep polling
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_error"] = f"iter {polls}: {type(e).__name__}: {e}"
_pll_monitor_events.append({
"t": time.time(),
"type": "dsim_fast_error",
"error": str(e),
})
time.sleep(0.1) # back off briefly so we don't spin on a persistent error
finally:
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_polls"] = polls
mm.close()
os.close(fd)
@app.route("/pll_monitor", methods=["PUT"])
def control_pll_monitor():
"""Start, stop, or clear the device-side PLL event monitor.
PUT /pll_monitor {"action":"start","interval_ms":10,"wide_interval_ms":500}
PUT /pll_monitor {"action":"stop"}
PUT /pll_monitor {"action":"clear"}
wide_interval_ms (optional, default 0 = disabled): if > 0, also runs a
second thread that snapshots all SN65 config/status registers at that
cadence and logs 'register_change' events on any non-trivial diff.
"""
global _pll_monitor_thread, _pll_wide_thread, _dsim_fast_thread
data = request.get_json(force=True) or {}
action = data.get("action", "").lower()
if action == "start":
if _pll_monitor_thread is not None and _pll_monitor_thread.is_alive():
return jsonify({"status": "already_running", "device_now": time.time()}), 200
interval_ms = max(5, int(data.get("interval_ms", PLL_MONITOR_DEFAULT_MS)))
wide_interval_ms = max(0, int(data.get("wide_interval_ms", 0)))
fast_dsim_interval_ms = max(0, int(data.get("fast_dsim_interval_ms", 0)))
_pll_monitor_stop.clear()
_pll_monitor_thread = threading.Thread(
target=_pll_monitor_loop, args=(interval_ms,), daemon=True
)
_pll_monitor_thread.start()
if wide_interval_ms > 0:
_pll_wide_thread = threading.Thread(
target=_pll_wide_loop, args=(wide_interval_ms,), daemon=True
)
_pll_wide_thread.start()
else:
_pll_wide_thread = None
if fast_dsim_interval_ms > 0:
_dsim_fast_thread = threading.Thread(
target=_dsim_fast_loop, args=(fast_dsim_interval_ms,), daemon=True
)
_dsim_fast_thread.start()
else:
_dsim_fast_thread = None
return jsonify({"status": "started", "interval_ms": interval_ms,
"wide_interval_ms": wide_interval_ms,
"fast_dsim_interval_ms": fast_dsim_interval_ms,
"device_now": time.time()}), 200
elif action == "stop":
_pll_monitor_stop.set()
if _pll_monitor_thread is not None:
_pll_monitor_thread.join(timeout=2)
if _pll_wide_thread is not None:
_pll_wide_thread.join(timeout=2)
if _dsim_fast_thread is not None:
_dsim_fast_thread.join(timeout=2)
return jsonify({"status": "stopped"}), 200
elif action == "clear":
with _pll_monitor_lock:
_pll_monitor_events.clear()
return jsonify({"status": "cleared"}), 200
else:
return jsonify({"error": "Invalid action. Use 'start', 'stop', or 'clear'"}), 400
@app.route("/pll_monitor/events", methods=["GET"])
def get_pll_monitor_events():
"""Return events with t > since (epoch seconds float), plus stats + device_now."""
since_raw = request.args.get("since", default="0")
try:
since_f = float(since_raw)
except ValueError:
since_f = 0.0
with _pll_monitor_lock:
events = [e for e in _pll_monitor_events if e["t"] > since_f]
stats = dict(_pll_monitor_stats)
return jsonify({"events": events, "stats": stats, "device_now": time.time()}), 200
@app.route("/sn65_testpattern", methods=["PUT"])
def control_testpattern():
"""Enable/disable the SN65DSI83 internal LVDS test pattern (CSR 0x2D bit 0).
PUT /sn65_testpattern {"state":"on"|"off"}
Test pattern is generated inside the SN65 at the LVDS output stage, downstream
of the MIPI input and conversion logic. Used to bisect flicker root cause:
if test pattern is clean while MIPI input flickers, fault is upstream of LVDS.
"""
data = request.get_json(force=True) or {}
state = data.get("state", "").lower()
if state not in ("on", "off"):
return jsonify({"error": "Invalid state. Use 'on' or 'off'"}), 400
# Known-working sequence (user-confirmed):
# i2cset -y -f 4 0x2c 0x3c 0x10 # enable
# i2cset -y -f 4 0x2c 0x3c 0x00 # disable
# Test pattern is bit 4 of CSR 0x3C (LVDS_FORMAT). Write whole byte to match.
current, err = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C)
if current is None:
return jsonify({"error": f"read 0x3C failed: {err}"}), 500
new = 0x10 if state == "on" else 0x00
ok, werr = _i2c_write_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C, new)
if not ok:
return jsonify({"error": f"write 0x3C failed: {werr}"}), 500
verify, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C)
return jsonify({
"status": "ok",
"state": state,
"register": "0x3C",
"before": f"0x{current:02x}",
"after": f"0x{verify:02x}" if verify is not None else None,
}), 200
@app.route("/video", methods=["PUT"]) @app.route("/video", methods=["PUT"])
def control_video(): def control_video():
"""Start or stop the kiosk video player. """Start or stop the kiosk video player.
@@ -375,6 +828,9 @@ def control_video():
mode = data.get("mode", "") mode = data.get("mode", "")
if mode == "static-pink": if mode == "static-pink":
cmd.append("--static-pink") cmd.append("--static-pink")
video = data.get("video")
if video:
cmd.extend(["--start", video])
_kiosk_args[:] = cmd # persist so control_display knows the mode _kiosk_args[:] = cmd # persist so control_display knows the mode
log = open("/tmp/kiosk.log", "w") log = open("/tmp/kiosk.log", "w")
_video_proc = subprocess.Popen( _video_proc = subprocess.Popen(

View File

@@ -1,18 +1,21 @@
import argparse
import gi import gi
import os
import socket import socket
import struct import struct
import threading import sys
import os
gi.require_version('Gst', '1.0') gi.require_version('Gst', '1.0')
from gi.repository import Gst, GLib from gi.repository import Gst, GLib
SWITCH_UDP_PORT = 5001
class KioskManager: class KioskManager:
def __init__(self, pipeline): def __init__(self, pipeline):
self.pipeline = pipeline self.pipeline = pipeline
self.videos = [ self.videos = [
"file:///root/vid.mp4", "file:///root/python/vid.mp4",
"file:///root/vid2.mp4" "file:///root/python/vid2.mp4"
] ]
self.current_video_index = 0 self.current_video_index = 0
@@ -117,7 +120,32 @@ def handle_button(source, condition, manager):
return True return True
def play_kiosk():
def handle_udp_switch(sock, condition, manager):
"""Receives 'switch' datagrams from device_server.py and cycles the video."""
try:
data, _ = sock.recvfrom(64)
except BlockingIOError:
return True
if data.strip() == b"switch":
print("UDP Trigger: switch")
manager.switch_video()
return True
def _resolve_start_index(start_name: str, videos: list) -> int:
"""Map a basename like 'vid.mp4' to its index in the videos list."""
target = os.path.basename(start_name).lower()
for i, uri in enumerate(videos):
if os.path.basename(uri).lower() == target:
return i
raise SystemExit(
f"--start {start_name!r} not in kiosk video list: "
+ ", ".join(os.path.basename(u) for u in videos)
)
def play_kiosk(start_index: int = 0):
Gst.init(None) Gst.init(None)
pipeline = Gst.ElementFactory.make("playbin", "player") pipeline = Gst.ElementFactory.make("playbin", "player")
@@ -129,17 +157,19 @@ def play_kiosk():
pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink")) pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink"))
manager = KioskManager(pipeline) manager = KioskManager(pipeline)
pipeline.set_property("uri", manager.videos[0]) manager.current_video_index = start_index
pipeline.set_property("uri", manager.videos[start_index])
print(f"Starting on: {manager.videos[start_index]}")
# UDP trigger → switch video (device_server sends a packet to port 5001) # --- UDP SWITCH LISTENER ---
def _udp_listener(): # device_server.py sends b'switch' to 127.0.0.1:5001 to cycle videos remotely.
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try:
sock.bind(('127.0.0.1', 5001)) udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True: udp_sock.setblocking(False)
sock.recv(64) udp_sock.bind(("127.0.0.1", SWITCH_UDP_PORT))
GLib.idle_add(manager.switch_video) GLib.io_add_watch(udp_sock, GLib.IO_IN, handle_udp_switch, manager)
except Exception as e:
threading.Thread(target=_udp_listener, daemon=True).start() print(f"UDP Listener Error: {e}")
# --- INPUT MONITORING --- # --- INPUT MONITORING ---
try: try:
@@ -159,16 +189,13 @@ def play_kiosk():
def on_message(bus, msg, manager_instance): def on_message(bus, msg, manager_instance):
if msg.type == Gst.MessageType.EOS: if msg.type == Gst.MessageType.EOS:
# Video ended. Cycle LED and advance to the next video in the list.
manager_instance.change_led_colour() manager_instance.change_led_colour()
pipeline.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0) manager_instance.switch_video()
elif msg.type == Gst.MessageType.ERROR: elif msg.type == Gst.MessageType.ERROR:
err, debug = msg.parse_error() err, debug = msg.parse_error()
print(f"GStreamer Error: {err}\nDebug: {debug}", flush=True) print(f"GStreamer Error: {err}")
loop.quit() loop.quit
elif msg.type == Gst.MessageType.STATE_CHANGED:
if msg.src == pipeline:
old, new, _ = msg.parse_state_changed()
print(f"Pipeline: {old.value_nick} -> {new.value_nick}", flush=True)
bus.connect("message", on_message, manager) bus.connect("message", on_message, manager)
@@ -180,73 +207,18 @@ def play_kiosk():
except KeyboardInterrupt: except KeyboardInterrupt:
pipeline.set_state(Gst.State.NULL) pipeline.set_state(Gst.State.NULL)
def play_static_color(r: int, g: int, b: int):
"""Display a solid colour using GStreamer videotestsrc (no video file required).
Uses videotestsrc pattern=solid-color so every DSI line carries the same
repeating RGB triplet — any deviation in the proto_decoder output is a DSI fault.
Listens on UDP port 5001 for a trigger packet (same as play_kiosk), which
briefly cycles the pipeline through READY→PLAYING to generate the LP→HS
startup sequence that the scope captures on Pass 1.
"""
Gst.init(None)
argb = (0xFF << 24) | (r << 16) | (g << 8) | b
SINK_STR = ("videoconvert ! video/x-raw,format=BGRx ! "
"kmssink driver-name=mxsfb-drm connector-id=37 plane-id=31 can-scale=false")
pipeline_str = (
f"videotestsrc pattern=solid-color foreground-color={argb} ! "
f"video/x-raw,width=1280,height=800,framerate=60/1 ! "
f"{SINK_STR}"
)
pipeline = Gst.parse_launch(pipeline_str)
bus = pipeline.get_bus()
bus.add_signal_watch()
loop = GLib.MainLoop()
def on_message(bus, msg):
if msg.type == Gst.MessageType.ERROR:
err, debug = msg.parse_error()
print(f"GStreamer Error: {err}\nDebug: {debug}", flush=True)
loop.quit()
elif msg.type == Gst.MessageType.STATE_CHANGED:
if msg.src == pipeline:
old, new, _ = msg.parse_state_changed()
print(f"Pipeline: {old.value_nick} -> {new.value_nick}", flush=True)
def _restart_pipeline():
"""Cycle READY→PLAYING to produce the LP→HS startup the scope triggers on."""
print("UDP trigger: restarting pipeline (READY → PLAYING)", flush=True)
pipeline.set_state(Gst.State.READY)
pipeline.set_state(Gst.State.PLAYING)
return False # GLib.idle_add one-shot
def _udp_listener():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('127.0.0.1', 5001))
while True:
sock.recv(64)
GLib.idle_add(_restart_pipeline)
threading.Thread(target=_udp_listener, daemon=True).start()
bus.connect("message", on_message)
pipeline.set_state(Gst.State.PLAYING)
print(f"Static colour R:{r} G:{g} B:{b} (0x{argb:08X}) — running", flush=True)
try:
loop.run()
except KeyboardInterrupt:
pipeline.set_state(Gst.State.NULL)
if __name__ == "__main__": if __name__ == "__main__":
import sys p = argparse.ArgumentParser(description="Kiosk video player")
if "--static-pink" in sys.argv: p.add_argument("--start", default="vid.mp4",
play_static_color(255, 51, 187) # R:255 G:51 B:187 help="Initial video filename (basename match against kiosk list)")
else: # parse_known_args so legacy flags like --static-pink don't crash the kiosk
play_kiosk() args, _unknown = p.parse_known_args()
# We need the video list to resolve --start, so we recreate it here (must
# stay in sync with KioskManager.videos).
_videos = [
"file:///root/python/vid.mp4",
"file:///root/python/vid2.mp4",
]
start_index = _resolve_start_index(args.start, _videos)
play_kiosk(start_index=start_index)

87
embed_frames.py Normal file
View File

@@ -0,0 +1,87 @@
"""Resize the flicker frame PNGs to embeddable JPGs and inject into the HTML report.
Reads flicker_investigation_handover.html, replaces the static "ul.tight" frame
list in Section 1 with an inline gallery of base64-embedded JPGs, and writes
back the same file.
"""
import base64
import sys
from pathlib import Path
from PIL import Image
REPO = Path(__file__).parent
PICS = REPO / "data" / "pics"
HTML = REPO / "flicker_investigation_continued.html"
TMP = REPO / ".embed_tmp"
# (filename, caption)
FRAMES = [
("frame0362.png", "Frame 362 — baseline. Clean image."),
("frame0363.png", "Frame 363 — flicker. Vertical displacement; content recognisable but geometry wrong."),
("frame0370.png", "Frame 370 — flicker. Same vertical shift pattern."),
("frame0376.png", "Frame 376 — flicker. Lower-amplitude shift."),
("frame0381.png", "Frame 381 — flicker (most dramatic). Multi-band tearing; multiple partial frames stacked vertically."),
("frame0382.png", "Frame 382 — flicker. Frame after the dramatic shift; partial recovery still showing artifacts."),
]
def resize_to_jpg(src: Path, dst: Path, width: int = 800, quality: int = 75) -> None:
"""Resize src to width px and write as JPG at given quality, via Pillow."""
img = Image.open(src)
if img.mode != "RGB":
img = img.convert("RGB")
w, h = img.size
if w > width:
new_h = int(h * width / w)
img = img.resize((width, new_h), Image.LANCZOS)
img.save(dst, "JPEG", quality=quality, optimize=True)
def main() -> int:
if not HTML.exists():
print(f"missing {HTML}", file=sys.stderr); return 1
TMP.mkdir(exist_ok=True)
blocks = []
for fname, caption in FRAMES:
src = PICS / fname
if not src.exists():
print(f"skip (missing): {src}"); continue
dst = TMP / (src.stem + ".jpg")
resize_to_jpg(src, dst)
b64 = base64.b64encode(dst.read_bytes()).decode("ascii")
kb = len(b64) * 3 // 4 // 1024
blocks.append(
f'<figure class="frame">\n'
f' <img src="data:image/jpeg;base64,{b64}" alt="{fname}">\n'
f' <figcaption>{caption}</figcaption>\n'
f'</figure>'
)
print(f"embedded {fname} -> {kb} KB")
if not blocks:
print("no frames embedded; HTML unchanged"); return 1
gallery = (
'<div class="frame-gallery">\n'
+ "\n".join(blocks)
+ "\n</div>"
)
html = HTML.read_text(encoding="utf-8")
placeholder = "<!-- FRAME_GALLERY_PLACEHOLDER -->"
if placeholder not in html:
print(f"WARNING: placeholder {placeholder!r} not found in HTML; "
"no embedding done", file=sys.stderr)
return 1
new_html = html.replace(placeholder, gallery, 1)
HTML.write_text(new_html, encoding="utf-8")
print(f"wrote {HTML} ({len(new_html)//1024} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())

231
explode_h5.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
explode_h5.py — split a Keysight segmented H5 file into per-segment CSVs.
When the scope is in segmented memory mode, a single :DISK:SAVE:WAVeform
call dumps all N segments into one .h5 file (much faster than saving N CSVs
sequentially). This script splits that file back into individual CSVs whose
names match the lp_ pattern that csv_preprocessor.analyze_lp_file() expects:
{ts}_lp_{cap_id}_seg{NNN}_{clk|dat}.csv
Usage:
python3 explode_h5.py <file.h5> [<file.h5> ...]
Or import explode() from this module.
Notes on Keysight Infiniium H5 layout:
The format used by :DISK:SAVE:WAVeform ... ,H5 nests waveform datasets
inside a "Waveforms"/"Channel N" group, with attributes XInc, XOrg,
YInc, YOrg, NumSegments, NumPoints, etc. We probe the structure
dynamically because slight variations exist between firmware versions.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import h5py
import numpy as np
LP_NAME_RE = re.compile(
r"(?P<ts>\d{8}_\d{6})_lp_(?P<id>c\d+_\d+|\d+)_(?P<chan>clk|dat)\.h5",
re.IGNORECASE,
)
def _walk(grp, depth: int = 0, max_depth: int = 4) -> list[tuple[str, h5py.Group]]:
"""Return all groups under `grp` up to max_depth, with their full paths."""
out = [(grp.name, grp)]
if depth >= max_depth:
return out
if isinstance(grp, h5py.Group):
for k in grp.keys():
try:
child = grp[k]
except Exception:
continue
if isinstance(child, h5py.Group):
out.extend(_walk(child, depth + 1, max_depth))
return out
def _find_segments(h5_root) -> tuple[h5py.Group, list[str], dict]:
"""
Locate the group that contains per-segment waveform datasets.
Returns (group, sorted_dataset_keys, attrs_dict). The attrs dict merges
attributes from the root, parent, and target group so we can find
XInc / XOrg / YInc / YOrg wherever Keysight chose to put them.
"""
groups = _walk(h5_root)
# Score each group by how many child *datasets* it has (segments are
# typically datasets named "Waveform 1", "Waveform 2", ... or
# "Channel 1", or just "1", "2", ...).
best = None
best_count = 0
for path, grp in groups:
if not isinstance(grp, h5py.Group):
continue
ds_keys = [k for k in grp.keys() if isinstance(grp[k], h5py.Dataset)]
# Filter: only datasets whose shape looks like a 1-D voltage trace
ds_keys = [
k for k in ds_keys
if grp[k].ndim == 1 and grp[k].size > 100
]
if len(ds_keys) > best_count:
best_count = len(ds_keys)
best = (grp, ds_keys)
if best is None or best_count == 0:
# 2-D dataset case: a single dataset of shape (N_segments, N_points)
for path, grp in groups:
for k in grp.keys() if isinstance(grp, h5py.Group) else []:
ds = grp[k]
if isinstance(ds, h5py.Dataset) and ds.ndim == 2 and ds.shape[0] > 1 and ds.shape[1] > 100:
return grp, [k], _collect_attrs(h5_root, grp, ds)
raise ValueError("No segment datasets found in H5")
grp, ds_keys = best
# Numerical sort if keys end with digits
ds_keys.sort(key=lambda s: (
int(re.search(r"\d+", s).group()) if re.search(r"\d+", s) else 0
))
return grp, ds_keys, _collect_attrs(h5_root, grp)
def _collect_attrs(*scopes) -> dict:
"""Merge attrs from multiple HDF5 nodes (later overrides earlier)."""
out = {}
for s in scopes:
try:
out.update({k: s.attrs[k] for k in s.attrs})
except Exception:
pass
return out
def _attr(attrs: dict, *names, default=None):
"""Return the first attribute that exists from a list of candidate names."""
for n in names:
if n in attrs:
v = attrs[n]
try:
# numpy scalar/bytes to native python
if isinstance(v, (bytes, bytearray)):
v = v.decode(errors="ignore")
if hasattr(v, "item") and getattr(v, "size", 1) == 1:
v = v.item()
except Exception:
pass
return v
return default
def explode(h5_path: Path, out_dir: Path | None = None,
verbose: bool = False) -> list[Path]:
"""
Split `h5_path` into per-segment CSVs.
Returns the list of CSV paths written. CSVs are placed in `out_dir`
(default: same dir as h5_path).
"""
h5_path = Path(h5_path)
out_dir = Path(out_dir) if out_dir else h5_path.parent
out_dir.mkdir(parents=True, exist_ok=True)
name_match = LP_NAME_RE.match(h5_path.name)
if not name_match:
raise ValueError(f"Not an LP H5 filename: {h5_path.name}")
ts = name_match["ts"]
cap_id = name_match["id"]
chan = name_match["chan"]
csvs: list[Path] = []
with h5py.File(h5_path, "r") as f:
grp, ds_keys, attrs = _find_segments(f)
x_inc = float(_attr(attrs, "XInc", "XIncrement", "x_increment", default=1e-10))
x_org = float(_attr(attrs, "XOrg", "XOrigin", "x_origin", default=0.0))
y_inc = _attr(attrs, "YInc", "YIncrement", "y_increment", default=None)
y_org = _attr(attrs, "YOrg", "YOrigin", "y_origin", default=None)
if verbose:
print(f" group: {grp.name} segments: {len(ds_keys)} "
f"XInc={x_inc:.3e} XOrg={x_org:.3e} YInc={y_inc} YOrg={y_org}")
# Single 2-D dataset case: shape (N_segments, N_points)
if len(ds_keys) == 1 and grp[ds_keys[0]].ndim == 2:
ds = grp[ds_keys[0]][:]
for i in range(ds.shape[0]):
volts = np.asarray(ds[i], dtype=float)
if y_inc is not None and y_org is not None:
volts = volts * float(y_inc) + float(y_org)
csvs.append(_write_segment_csv(
out_dir, ts, cap_id, chan, i + 1, x_inc, x_org, volts,
))
return csvs
# Multi-dataset case: each dataset is one segment
for i, key in enumerate(ds_keys, start=1):
volts = np.asarray(grp[key][:], dtype=float)
if y_inc is not None and y_org is not None:
# Some Keysight files store raw codes that need scaling
if np.issubdtype(grp[key].dtype, np.integer):
volts = volts * float(y_inc) + float(y_org)
csvs.append(_write_segment_csv(
out_dir, ts, cap_id, chan, i, x_inc, x_org, volts,
))
return csvs
def _write_segment_csv(out_dir: Path, ts: str, cap_id: str, chan: str,
seg_idx: int, x_inc: float, x_org: float,
volts: np.ndarray) -> Path:
n = len(volts)
times = np.arange(n) * x_inc + x_org
csv_path = out_dir / f"{ts}_lp_{cap_id}_seg{seg_idx:03d}_{chan}.csv"
np.savetxt(
csv_path,
np.column_stack([times, volts]),
delimiter=",",
fmt="%.6e",
)
return csv_path
def inspect(h5_path: Path) -> None:
"""Print the H5 hierarchy + attrs. Useful for debugging unknown files."""
with h5py.File(h5_path, "r") as f:
def visit(name, obj):
if isinstance(obj, h5py.Group):
kind = "GROUP"
shape = ""
else:
kind = "DSET"
shape = f" shape={obj.shape} dtype={obj.dtype}"
print(f" {kind} /{name}{shape}")
for k, v in obj.attrs.items():
vs = str(v)[:60]
print(f" attr {k} = {vs}")
f.visititems(visit)
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
print(__doc__)
sys.exit(0)
if args[0] == "--inspect":
for p in args[1:]:
print(f"\n=== {p} ===")
inspect(Path(p))
sys.exit(0)
for p in args:
try:
outs = explode(Path(p), verbose=True)
print(f"{Path(p).name}: {len(outs)} segment(s) → CSVs")
except Exception as e:
print(f"{Path(p).name}: ERROR — {e}")

625
flicker_burst.py Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

594
flicker_watch.py Normal file
View File

@@ -0,0 +1,594 @@
#!/usr/bin/env python3
"""
flicker_watch.py — Continuous LP capture during video on/off cycles.
Operator watches the display. Script keeps cycling the video stream on/off
and triggering LP captures in the background. Files accumulate on the scope
without being transferred (fast).
Keys (no Enter needed):
f — flicker observed: transfer + archive + analyse recent captures
g — good baseline: transfer + archive recent captures (no analysis)
q — quit
Captures are organised under data/flicker/{event_ts}/ or data/good/{event_ts}/.
"""
import json
import select
import shutil
import sys
import termios
import time
import tty
from datetime import datetime
from pathlib import Path
import numpy as np
import requests
import vxi11
from csv_preprocessor import analyze_lp_file
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SCOPE_IP = "192.168.45.4"
DEVICE_BASE = "http://192.168.45.8:5000"
VIDEO_URL = f"{DEVICE_BASE}/video"
DATA_DIR = Path(__file__).parent / "data"
FLICKER_DIR = DATA_DIR / "flicker"
GOOD_DIR = DATA_DIR / "good"
# Trigger mode:
# "LP_DAT" — falling-edge on DAT0+ (CH3) crossing 0.6 V. Fires on every
# LP-to-HS transition (≈ line rate, 48 kHz). Use to sample
# normal MIPI traffic and spot per-burst anomalies.
# "CLK_GLITCH" — timeout trigger on CLK+ (CH1) staying HIGH > N ms. Fires
# *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 = "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
# discriminates real unlocks from normal MIPI line/frame breaks.
CLK_GLITCH_HIGH_MS = 100.0 # CLK+ HIGH longer than this fires the trigger
# Capture window
# LP_DAT mode: 1 µs/div × 20 div = 20 µs window (50k pts → 5 GSa/s)
# CLK_GLITCH: 20 ms/div × 20 div = 400 ms window (200k pts → 500 kSa/s)
# wide enough to bracket a 150 ms event with margin on both sides
if TRIGGER_MODE == "CLK_GLITCH":
LP_SCALE = 20e-3
LP_POINTS = 200_000
LP_TRIG_OFFSET = 0.0 # centre the trigger so we see before+after
SEGMENT_COUNT = 1 # one big window per acquire is plenty
else:
LP_SCALE = 1e-6
LP_POINTS = 50_000
LP_TRIG_OFFSET = 9e-6
SEGMENT_COUNT = 100
LP_V_SCALE = 0.2
LP_V_OFFSET = 0.6
LP_TRIG_LEVEL = 0.6
CYCLE_S = 10.0
# CLK_GLITCH triggers can take many seconds (or never come) — give it the full
# cycle. LP_DAT triggers fill 100 segments in well under a second.
TRIG_TIMEOUT_S = CYCLE_S - 0.5 if TRIGGER_MODE == "CLK_GLITCH" \
else max(SEGMENT_COUNT * 0.020 + 10.0, 15.0)
# ---------------------------------------------------------------------------
# Scope setup
# ---------------------------------------------------------------------------
scope = vxi11.Instrument(SCOPE_IP)
scope.timeout = 30
def _drain_scpi_errors(label: str = "") -> list[str]:
"""Pop everything from the scope's error queue; return list of error strings."""
errs = []
for _ in range(20):
try:
r = scope.ask(":SYSTem:ERRor?").strip()
except Exception:
break
if not r or r.startswith("0,") or r.startswith("+0,") or r == "0":
break
errs.append(r)
if errs and label:
print(f" [{label}] SCPI errors: {errs}")
return errs
def setup_scope() -> None:
"""One-shot scope init — channels, math, default trigger."""
print("CONFIGURING SCOPE...")
try:
idn = scope.ask("*IDN?").strip()
print(f" IDN: {idn}")
except Exception as e:
print(f" IDN read failed: {e}")
cmds = [
"*RST", ":RUN", ":STOP", "*CLS",
":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50", ":CHANnel1:PROBe 19.2",
":CHANnel1:LABel 'CLK+'",
":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50", ":CHANnel2:PROBe 19.2",
":CHANnel2:LABel 'CLK-'",
":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50", ":CHANnel3:PROBe 19.2",
":CHANnel3:LABel 'DAT0+'",
":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50", ":CHANnel4:PROBe 19.2",
":CHANnel4:LABel 'DAT0-'",
":TIMebase:REFerence CENTer",
":TRIGger:MODE EDGE",
":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON",
":DISPlay:LAYout STACKED",
]
for c in cmds:
scope.write(c)
time.sleep(0.05)
_drain_scpi_errors("setup_scope")
print("SCOPE READY.")
def _read_ieee_block() -> bytes:
"""
Read an IEEE 488.2 definite-length binary block from the scope:
'#' <ndigits> <length> <data> [\\n]
"""
# Read header: '#' then one digit telling us how many length-digits follow.
head = scope.read_raw(2)
if not head.startswith(b"#"):
# Sometimes vxi11 returns a longer chunk; locate the '#'
idx = head.find(b"#")
if idx < 0:
extra = scope.read_raw(64)
head = head + extra
idx = head.find(b"#")
head = head[idx:idx + 2]
ndigits = int(head[1:2])
if ndigits == 0:
# "#0..." indicates indefinite-length; read until newline.
return scope.read_raw().rstrip(b"\r\n")
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
# Discard the trailing newline if present
try:
scope.read_raw(1)
except Exception:
pass
return data
def configure_for_lp() -> None:
"""LP-mode capture, with trigger configured per TRIGGER_MODE."""
for ch in (1, 2, 3, 4):
scope.write(f":CHANnel{ch}:SCALe {LP_V_SCALE:.3f}")
scope.write(f":CHANnel{ch}:OFFSet {LP_V_OFFSET:.3f}")
if TRIGGER_MODE == "CLK_GLITCH":
# Pulse-width (GLITch) trigger on the Infiniium A/B (firmware 5.x):
# fires at the falling edge of a CH1 (CLK+) HIGH pulse longer than
# CLK_GLITCH_HIGH_MS — i.e. CLK held LP-11 for an unusually long time.
# The newer :TRIGger:TIMeout:* SCPI is rejected by this scope (-113).
_drain_scpi_errors()
scope.write(":TRIGger:MODE GLITch")
scope.write(":TRIGger:GLITch:SOURce CHANnel1")
scope.write(":TRIGger:GLITch:POLarity POSitive")
scope.write(":TRIGger:GLITch:DIRection GREaterthan")
scope.write(f":TRIGger:GLITch:WIDTh {CLK_GLITCH_HIGH_MS * 1e-3:.3E}")
scope.write(f":TRIGger:GLITch:LEVel CHANnel1,{LP_TRIG_LEVEL:.3f}")
time.sleep(0.2)
errs = _drain_scpi_errors()
if errs:
print(f" GLITch trigger setup SCPI errors: {errs}")
try:
mode = scope.ask(":TRIGger:MODE?").strip()
w = scope.ask(":TRIGger:GLITch:WIDTh?").strip()
print(f" GLITch trigger: mode={mode} CLK+ HIGH > {float(w)*1000:.1f} ms")
except Exception as e:
print(f" GLITch trigger readback failed: {e}")
else:
# Edge trigger on falling DAT0+: fires on every LP-to-HS transition.
scope.write(":TRIGger:MODE EDGE")
scope.write(":TRIGger:EDGE:SOURce CHANnel3")
scope.write(":TRIGger:EDGE:SLOPe NEGative")
scope.write(f":TRIGger:EDGE:LEVel {LP_TRIG_LEVEL:.3f}")
scope.write(":TRIGger:SWEep NORMal")
scope.write(f":TIMebase:SCALe {LP_SCALE:.3E}")
scope.write(f":ACQuire:POINts {LP_POINTS}")
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
if SEGMENT_COUNT > 1:
scope.write(":ACQuire:MODE SEGMented")
scope.write(f":ACQuire:SEGMented:COUNt {SEGMENT_COUNT}")
else:
scope.write(":ACQuire:MODE RTIMe")
time.sleep(0.5)
_drain_scpi_errors("configure_for_lp")
def arm_and_wait(timeout_s: float) -> bool:
""":DIGitize + *OPC?. Returns True if trigger fired within timeout."""
global scope
prev = scope.timeout
try:
scope.timeout = timeout_s + 2
scope.write(":DIGitize")
return scope.ask("*OPC?").strip() == "1"
except Exception:
# Trigger timed out or scope locked up — reconnect.
try:
scope.close()
except Exception:
pass
time.sleep(1.0)
scope = vxi11.Instrument(SCOPE_IP)
scope.timeout = 30
try:
scope.write(":STOP")
except Exception:
pass
return False
finally:
try:
scope.timeout = prev
except Exception:
pass
def _fetch_channel_segments(channel: int, n_segments: int):
"""
Read all segments for one channel via :WAVeform:DATA?. Returns
(times_ndarray, list_of_volts_ndarrays). Time axis is shared across all
segments. When n_segments == 1 we skip the SEGMented:INDex select since
we may be in RTIMe (single-shot) mode rather than SEGMented mode.
"""
import numpy as np
scope.write(f":WAVeform:SOURce CHANnel{channel}")
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: list = []
for i in range(1, n_segments + 1):
if n_segments > 1:
scope.write(f":ACQuire:SEGMented:INDex {i}")
scope.write(":WAVeform:DATA?")
raw = _read_ieee_block()
codes = np.frombuffer(raw, dtype="<i2")
volts = codes.astype(np.float64) * y_inc + y_org
segs.append(volts)
n = len(segs[0]) if segs else 0
times = np.arange(n) * x_inc + x_org
return times, segs
def save_lp(base_name: str) -> tuple[bool, list[str]]:
"""
Read all N segments for CLK and DAT directly via VXI-11 binary transfer
and write per-segment CSVs locally to DATA_DIR.
Returns (ok, errs). Filenames match csv_preprocessor's expected pattern:
{base_name}_seg{NNN}_{clk|dat}.csv
"""
import numpy as np
_drain_scpi_errors()
try:
t_clk, clk_segs = _fetch_channel_segments(1, SEGMENT_COUNT)
t_dat, dat_segs = _fetch_channel_segments(3, SEGMENT_COUNT)
except Exception as e:
return (False, [f"fetch error: {e}"])
errs = _drain_scpi_errors()
n_written = 0
for i, (clk, dat) in enumerate(zip(clk_segs, dat_segs), start=1):
clk_path = DATA_DIR / f"{base_name}_seg{i:03d}_clk.csv"
dat_path = DATA_DIR / f"{base_name}_seg{i:03d}_dat.csv"
np.savetxt(clk_path, np.column_stack([t_clk, clk]),
delimiter=",", fmt="%.6e")
np.savetxt(dat_path, np.column_stack([t_dat, dat]),
delimiter=",", fmt="%.6e")
n_written += 1
if n_written == 0:
return (False, errs or ["no segments written"])
return (True, errs)
# ---------------------------------------------------------------------------
# Non-blocking keyboard
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Video control
# ---------------------------------------------------------------------------
def video_start() -> None:
try:
requests.put(VIDEO_URL,
json={"action": "start", "mode": "static-pink"},
timeout=3)
except requests.exceptions.RequestException as e:
print(f" VIDEO START failed: {e}")
def video_stop() -> None:
try:
requests.put(VIDEO_URL, json={"action": "stop"}, timeout=3)
except requests.exceptions.RequestException as e:
print(f" VIDEO STOP failed: {e}")
# ---------------------------------------------------------------------------
# Register snapshot from device (DSIM PHY + SN65DSI83)
# ---------------------------------------------------------------------------
def fetch_registers_snapshot(target_dir: Path, event_ts: str) -> None:
"""GET /registers + /sn65_registers, print key indicators, save JSON."""
combined: dict = {}
for endpoint, key in [("/registers", "dsim"),
("/sn65_registers", "sn65")]:
try:
r = requests.get(f"{DEVICE_BASE}{endpoint}", timeout=5)
r.raise_for_status()
combined[key] = r.json()
except Exception as e:
print(f" REGISTERS: {endpoint} failed — {e}")
combined[key] = None
# Quick-look indicators
sn65 = combined.get("sn65") or {}
regs = sn65.get("registers", {}) if isinstance(sn65, dict) else {}
csr_0a = regs.get("csr_0a", {}) or {}
csr_e5 = regs.get("csr_e5", {}) or {}
if csr_0a:
pll_str = "LOCKED" if csr_0a.get("pll_lock") else "*** UNLOCKED ***"
clk_str = "detected" if csr_0a.get("clk_det") else "NOT detected"
print(f" SN65: PLL {pll_str} CLK {clk_str} (CSR 0x0A = {csr_0a.get('value')})")
if csr_e5:
flags = [
("pll_unlock", "PLL_UNLOCK"),
("cha_sot_bit_err", "SOT_BIT_ERR"),
("cha_llp_err", "LLP_ERR"),
("cha_ecc_err", "ECC_ERR"),
("cha_lp_err", "LP_ERR"),
("cha_crc_err", "CRC_ERR"),
]
active = [label for k, label in flags if csr_e5.get(k)]
if active:
print(f" SN65: *** ERROR FLAGS: {', '.join(active)} "
f"(CSR 0xE5 = {csr_e5.get('value')}) ***")
else:
print(f" SN65: no error flags (CSR 0xE5 = {csr_e5.get('value')})")
out = target_dir / f"{event_ts}_registers.json"
try:
out.write_text(json.dumps(combined, indent=2))
print(f" registers → {out.relative_to(DATA_DIR.parent)}")
except Exception as e:
print(f" REGISTERS save failed: {e}")
# ---------------------------------------------------------------------------
# Event handling: archive recent captures and (for flicker) analyse
# ---------------------------------------------------------------------------
def archive_and_analyse(event: str, since_iso: str) -> None:
"""
Pull every CSV from the scope, move into data/{event}/{event_ts}/.
For flicker events, run csv_preprocessor on each LP capture and print a
summary table. Always pulls a register snapshot from the device too.
"""
event_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = (FLICKER_DIR if event == "flicker" else GOOD_DIR) / event_ts
target.mkdir(parents=True, exist_ok=True)
print(f"\n *** {event.upper()} EVENT @ {event_ts} ***")
# Register snapshot first (fast, before scope transfer which takes longer)
fetch_registers_snapshot(target, event_ts)
# Segment CSVs are already in DATA_DIR (written directly by save_lp via
# SCPI binary read). Just move the ones from this event into the folder.
moved = 0
for f in DATA_DIR.glob("*.csv"):
if f.is_file():
shutil.move(str(f), target / f.name)
moved += 1
print(f" {moved} segment CSV(s) archived to {target.relative_to(DATA_DIR.parent)}")
if event != "flicker":
return
# Analyse every segment CSV. Flag outliers.
print("\n Per-segment LP analysis:")
rows = []
for f in sorted(target.glob("*_lp_*_dat.csv")):
try:
m = analyze_lp_file(f)
rows.append({
"file": f.name,
"lp_low": float(m.lp_low_duration_ns) if m.lp_low_duration_ns is not None else None,
"hs_amp": float(m.hs_amplitude_mv) if m.hs_amplitude_mv is not None else None,
"hs_dur": float(m.hs_burst_dur_ns) if m.hs_burst_dur_ns is not None else None,
"n_burst": int(m.n_hs_bursts) if m.n_hs_bursts is not None else None,
"sus": bool(m.flicker_suspect),
})
except Exception as e:
rows.append({"file": f.name, "error": str(e)})
n_total = len(rows)
n_sus = sum(1 for r in rows if r.get("sus"))
print(f" {n_total} segments analysed ({n_sus} flagged as flicker_suspect)")
# Outlier search across the segments themselves.
def _outliers(field: str, lo_thresh: float | None = None,
hi_thresh: float | None = None) -> list[dict]:
vals = sorted(r[field] for r in rows if r.get(field) is not None)
if not vals:
return []
med = vals[len(vals) // 2]
out = []
for r in rows:
v = r.get(field)
if v is None: continue
far = (lo_thresh is not None and v < lo_thresh) or \
(hi_thresh is not None and v > hi_thresh)
if far:
out.append({"file": r["file"], field: v, "median": med})
return out
print("\n Anomalies vs segment-set median:")
for label, field, lo, hi in [
("very-short LP-low (<50 ns)", "lp_low", 50, None),
("very-low HS amplitude (<50 mV)", "hs_amp", 50, None),
("very-high HS amplitude (>140 mV)","hs_amp", None, 140),
("short HS burst (<8000 ns)", "hs_dur", 8000, None),
]:
ax = _outliers(field, lo, hi)
if ax:
print(f" {label}: {len(ax)} segment(s)")
for x in ax[:8]:
print(f" {x['file']} {field}={x[field]:.1f} "
f"(set median={x['median']:.1f})")
if len(ax) > 8:
print(f" ... +{len(ax) - 8} more")
else:
print(f" {label}: none")
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main() -> None:
DATA_DIR.mkdir(exist_ok=True)
FLICKER_DIR.mkdir(exist_ok=True)
GOOD_DIR.mkdir(exist_ok=True)
setup_scope()
configure_for_lp()
print("\n" + "=" * 64)
print(" FLICKER WATCH — keys: f=flicker g=good q=quit")
print("=" * 64 + "\n")
cycle = 0
try:
with KeyReader() as keys:
while True:
cycle += 1
cycle_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
cycle_caps = []
cycle_end = time.time() + CYCLE_S
video_start()
mode_desc = (
f"CLK_GLITCH (CLK+ HIGH > {CLK_GLITCH_HIGH_MS:.1f} ms, "
f"{LP_SCALE * 20 * 1000:.0f} ms window)"
if TRIGGER_MODE == "CLK_GLITCH"
else f"LP_DAT ({SEGMENT_COUNT} segs/acquire)"
)
print(f"\n[cycle {cycle:03d} {cycle_ts}] video ON "
f"({CYCLE_S:.0f}s window, {mode_desc})", flush=True)
event = None
last_tick = 0.0
while time.time() < cycle_end:
seq = len(cycle_caps) + 1
base = f"{cycle_ts}_lp_c{cycle:03d}_{seq:02d}"
remaining = lambda: max(0, cycle_end - time.time())
if arm_and_wait(TRIG_TIMEOUT_S):
try:
ok, errs = save_lp(base)
if ok:
cycle_caps.append(base)
tag = ("CLK GLITCH" if TRIGGER_MODE == "CLK_GLITCH"
else f"{SEGMENT_COUNT} segs")
print(f" + acq {seq:02d} ({tag}) "
f"[{remaining():4.1f}s left]",
flush=True)
else:
print(f" ! acq {seq:02d} SAVE FAILED — "
f"{errs[0][:80] if errs else 'unknown'}",
flush=True)
except Exception as e:
print(f" save error: {e}", flush=True)
else:
# Trigger timed out — print a heartbeat at most every 2s.
# In CLK_GLITCH mode this is the *normal* state: it just
# means no glitch happened during this cycle.
if time.time() - last_tick > 2.0:
msg = ("waiting for CLK glitch"
if TRIGGER_MODE == "CLK_GLITCH"
else "waiting for trigger")
print(f" ... {msg} "
f"[{remaining():4.1f}s left]", flush=True)
last_tick = time.time()
key = keys.get_key()
if key in ("f", "g", "q"):
event = key
break
video_stop()
if event is None:
print(f"[cycle {cycle:03d}] ended "
f"({len(cycle_caps)} acq(s) ≈ "
f"{len(cycle_caps) * SEGMENT_COUNT} segments, no event)",
flush=True)
if event == "f":
archive_and_analyse("flicker", cycle_ts)
elif event == "g":
archive_and_analyse("good", cycle_ts)
elif event == "q":
print("\nQUIT requested.")
break
# Brief pause before next cycle so video stop settles.
time.sleep(0.5)
except KeyboardInterrupt:
print("\nInterrupted (Ctrl+C).")
finally:
try:
video_stop()
except Exception:
pass
if __name__ == "__main__":
main()

812
make_flicker_report.py Normal file
View File

@@ -0,0 +1,812 @@
#!/usr/bin/env python3
"""
make_flicker_report.py — render an HTML root-cause report for a
flicker_burst.py session, in the same style as flicker_investigation_report.html.
Usage:
python3 make_flicker_report.py \
--session data/flicker_bursts/20260515_135656 \
--genuine 4,5,8,11,13,14,15,16,17,18,19 \
--out flicker_investigation_report_v2.html
"""
from __future__ import annotations
import argparse
import base64
import io
import json
import re
from collections import Counter
from datetime import datetime
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# Style choices to match Arrive corporate palette in the existing report
ARRIVE_PURPLE = "#5f016f"
ARRIVE_PURPLE_DARK = "#3e0049"
ARRIVE_PINK = "#ff32a2"
ARRIVE_TINT = "#faf3fb"
PASS_GREEN = "#1a7f37"
FAIL_RED = "#c62a3d"
WARN_AMBER = "#b58105"
ERR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def find_burst_files(session_dir: Path, burst_n: int) -> dict:
pll_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_pll_samples.json"))
rail_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_rail.csv"))
clk_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_clk.csv"))
dat_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_dat.csv"))
meta_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_meta.json"))
return {
"pll": pll_files[0] if pll_files else None,
"rail": rail_files[0] if rail_files else None,
"clk": clk_files,
"dat": dat_files,
"meta": meta_files[0] if meta_files else None,
}
def analyse_burst(session_dir: Path, burst_n: int) -> dict | None:
files = find_burst_files(session_dir, burst_n)
if not files["pll"]:
return None
d = json.loads(files["pll"].read_text())
samples = d["samples"]
n_lock = n_unlock = n_none = n_err = 0
csr_0a = Counter(); csr_e5 = Counter(); err_bits = Counter()
for s in samples:
if "error" in s:
n_err += 1; continue
st = s["state"]
pll = st.get("pll_lock")
if pll is True: n_lock += 1
elif pll is False: n_unlock += 1
else: n_none += 1
csr_0a[st.get("csr_0a")] += 1
csr_e5[st.get("csr_e5")] += 1
for b in ERR_BITS:
if st.get(b): err_bits[b] += 1
rail_vpp = rail_mean = rail_min = rail_max = rail_std = None
if files["rail"] and files["rail"].exists():
arr = np.genfromtxt(files["rail"], delimiter=",")
v = arr[:, 1] * 1000
rail_vpp = float(v.max() - v.min())
rail_mean = float(v.mean())
rail_min = float(v.min())
rail_max = float(v.max())
rail_std = float(v.std())
mipi_vpps = []
for f in files["clk"]:
arr = np.genfromtxt(f, delimiter=",")
v = arr[:, 1]
mipi_vpps.append((v.max() - v.min()) * 1000)
mipi_vpps_s = sorted(mipi_vpps) if mipi_vpps else []
return {
"burst": burst_n,
"press_iso": d["press_iso"],
"duration_s": d["duration_s"],
"n_samples": d["n_samples"],
"n_unlocks": d["n_unlocks"],
"n_lock": n_lock,
"n_unlock_s": n_unlock,
"n_none": n_none,
"n_err": n_err,
"csr_0a": dict(csr_0a),
"csr_e5": dict(csr_e5),
"err_bits": dict(err_bits),
"unlock_pairs": d.get("unlock_pairs", []),
"rail_vpp": rail_vpp,
"rail_mean": rail_mean,
"rail_min": rail_min,
"rail_max": rail_max,
"rail_std": rail_std,
"rail_path": files["rail"],
"clk_files": files["clk"],
"dat_files": files["dat"],
"mipi_vpp_min": min(mipi_vpps_s) if mipi_vpps_s else None,
"mipi_vpp_med": mipi_vpps_s[len(mipi_vpps_s)//2] if mipi_vpps_s else None,
"mipi_vpp_max": max(mipi_vpps_s) if mipi_vpps_s else None,
"n_segs": len(files["clk"]),
}
def save_fig(fig, out_dir: Path, name: str) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{name}.png"
fig.savefig(path, format="png", dpi=110, bbox_inches="tight",
facecolor="white")
plt.close(fig)
return path
def plot_rail(rail_path: Path, title: str, out_dir: Path, name: str,
highlight_color: str = ARRIVE_PURPLE) -> Path:
arr = np.genfromtxt(rail_path, delimiter=",")
t = arr[:, 0]
v = arr[:, 1] * 1000 # mV
fig, ax = plt.subplots(figsize=(8.5, 2.6))
ax.plot(t, v, color=highlight_color, linewidth=0.8)
ax.axhline(1800, color="grey", linestyle="--", linewidth=0.5, alpha=0.5)
ax.set_xlabel("time (s, relative to Rigol trigger)")
ax.set_ylabel("1V8 rail (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.grid(True, alpha=0.25)
ax.set_ylim(1700, 1900)
ax.text(0.99, 0.97,
f"mean = {v.mean():.1f} mV Vpp = {v.max()-v.min():.1f} mV",
transform=ax.transAxes, ha="right", va="top",
fontsize=9, color=ARRIVE_PURPLE_DARK,
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
return save_fig(fig, out_dir, name)
def plot_mipi_segment(seg_clk: Path, seg_dat: Path, title: str,
out_dir: Path, name: str) -> Path:
arr_c = np.genfromtxt(seg_clk, delimiter=",")
arr_d = np.genfromtxt(seg_dat, delimiter=",")
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000 # ns, mV
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
fig, ax = plt.subplots(figsize=(8.5, 2.6))
ax.plot(t_c, v_c, color=ARRIVE_PURPLE, linewidth=0.7, label="CLK+ (single-ended)")
ax.plot(t_d, v_d, color=ARRIVE_PINK, linewidth=0.7, label="DAT0+ (single-ended)")
ax.set_xlabel("time (ns)")
ax.set_ylabel("voltage (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.legend(loc="upper right", fontsize=9, frameon=True)
ax.grid(True, alpha=0.25)
return save_fig(fig, out_dir, name)
def plot_mipi_overlay(seg_paths: list[Path], title: str, channel: str,
out_dir: Path, name: str, n_overlay: int = 20) -> Path:
"""Overlay first N segments to give a 'composite eye / typical envelope'."""
fig, ax = plt.subplots(figsize=(8.5, 2.6))
for f in seg_paths[:n_overlay]:
arr = np.genfromtxt(f, delimiter=",")
t = arr[:, 0] * 1e9
v = arr[:, 1] * 1000
ax.plot(t, v, color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.4)
ax.set_xlabel("time (ns)")
ax.set_ylabel(f"{channel} (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.grid(True, alpha=0.25)
return save_fig(fig, out_dir, name)
def _find_lp_to_hs_idx(v: np.ndarray, hi_thresh: float = 0.5) -> int | None:
"""Find sample index of the LP-11 → HS transition (first time v falls
below hi_thresh after starting above it). Returns None if not found."""
above = v > hi_thresh
if not above.any() or above.all():
return None
# Find a contiguous block of "above" then the first "below" after it
first_above = int(np.argmax(above))
for i in range(first_above + 1, len(v)):
if not above[i]:
return i
return None
def plot_mipi_zoom_transition(seg_clk: Path, seg_dat: Path, title: str,
out_dir: Path, name: str,
half_window_ns: float = 60.0) -> Path:
"""Zoom in on the LP-11 → HS transition: ±half_window_ns around the
falling edge. Shows the SoT preamble and start of HS oscillation."""
arr_c = np.genfromtxt(seg_clk, delimiter=",")
arr_d = np.genfromtxt(seg_dat, delimiter=",")
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
idx = _find_lp_to_hs_idx(arr_c[:, 1])
if idx is None:
idx = len(arr_c) // 4
t_edge = t_c[idx]
lo = t_edge - half_window_ns; hi = t_edge + half_window_ns
mask = (t_c >= lo) & (t_c <= hi)
fig, ax = plt.subplots(figsize=(8.5, 2.8))
ax.plot(t_c[mask], v_c[mask], color=ARRIVE_PURPLE, linewidth=0.9,
label="CLK+")
mask_d = (t_d >= lo) & (t_d <= hi)
ax.plot(t_d[mask_d], v_d[mask_d], color=ARRIVE_PINK, linewidth=0.9,
label="DAT0+")
ax.axvline(t_edge, color="grey", linestyle=":", linewidth=0.7, alpha=0.7,
label=f"LP→HS edge")
ax.set_xlabel("time (ns)")
ax.set_ylabel("voltage (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.legend(loc="upper right", fontsize=9, frameon=True)
ax.grid(True, alpha=0.25)
return save_fig(fig, out_dir, name)
def plot_mipi_zoom_hs(seg_clk: Path, title: str, out_dir: Path, name: str,
offset_ns: float = 200.0, window_ns: float = 50.0) -> Path:
"""Zoom in on HS oscillation: window_ns starting offset_ns AFTER the
LP-to-HS edge. Should show ~20 clock cycles at 216 MHz toggling cleanly."""
arr = np.genfromtxt(seg_clk, delimiter=",")
t = arr[:, 0] * 1e9
v = arr[:, 1] * 1000
idx = _find_lp_to_hs_idx(arr[:, 1])
if idx is None:
idx = len(arr) // 4
t_edge = t[idx]
lo = t_edge + offset_ns
hi = lo + window_ns
mask = (t >= lo) & (t <= hi)
fig, ax = plt.subplots(figsize=(8.5, 2.8))
ax.plot(t[mask], v[mask], color=ARRIVE_PURPLE, linewidth=1.0,
marker=".", markersize=2)
ax.axhline(v[mask].mean(), color="grey", linestyle=":", linewidth=0.6,
alpha=0.6, label=f"common mode ≈ {v[mask].mean():.0f} mV")
ax.set_xlabel("time (ns)")
ax.set_ylabel("CLK+ (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.legend(loc="upper right", fontsize=9, frameon=True)
ax.grid(True, alpha=0.25)
ax.text(0.01, 0.04,
f"Vpp = {v[mask].max()-v[mask].min():.0f} mV",
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
return save_fig(fig, out_dir, name)
def plot_eye(seg_paths: list[Path], title: str, out_dir: Path, name: str,
n_segs: int = 20,
offset_ns: float = 200.0, window_ns: float = 200.0,
ui_ns: float = 2.315) -> Path:
"""
Folded-overlay eye diagram of HS oscillation: each segment's HS region
(offset..offset+window after the LP→HS edge) is sliced at every zero-
crossing and overlaid on a 2-UI horizontal scale.
"""
fig, ax = plt.subplots(figsize=(8.5, 3.0))
n_plotted = 0
for f in seg_paths[:n_segs]:
arr = np.genfromtxt(f, delimiter=",")
t = arr[:, 0] * 1e9
v = arr[:, 1] * 1000
edge_idx = _find_lp_to_hs_idx(arr[:, 1])
if edge_idx is None:
continue
t_edge = t[edge_idx]
lo = t_edge + offset_ns
hi = lo + window_ns
mask = (t >= lo) & (t <= hi)
t_hs = t[mask]
v_hs = v[mask]
if len(v_hs) < 4: continue
cm = float(v_hs.mean())
# Zero crossings (above/below CM transitions)
sign = (v_hs > cm).astype(int)
edges = np.where(np.diff(sign) != 0)[0]
for e in edges:
# Take 1 UI before and 1 UI after this crossing
t_cross = t_hs[e]
sl_mask = (t_hs >= t_cross - ui_ns) & (t_hs <= t_cross + ui_ns)
if sl_mask.sum() < 3: continue
ax.plot(t_hs[sl_mask] - t_cross, v_hs[sl_mask],
color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.25)
n_plotted += 1
ax.axhline(0, color="grey", linewidth=0.4, alpha=0.5)
ax.set_xlabel(f"time (ns, folded on UI = {ui_ns} ns)")
ax.set_ylabel("CLK+ (mV)")
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
ax.grid(True, alpha=0.25)
ax.text(0.01, 0.95, f"{n_plotted} segments × ~80 cycles overlaid",
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85), va="top")
return save_fig(fig, out_dir, name)
def get_template_styles_and_banner() -> str:
"""Extract <head> + banner from the existing template so colours/logo match.
The banner has a nested <div class="who">, so we need the SECOND </div>
after class="banner" — i.e. banner's own closer, not the nested div's.
"""
template = Path(__file__).parent / "flicker_investigation_report.html"
text = template.read_text()
head_end = text.find("</head>")
body_start = text.find("<body>")
# Walk past two </div> tags to clear the nested "who" div + the banner itself
pos = text.find('class="banner"')
for _ in range(2):
pos = text.find("</div>", pos) + len("</div>")
body_end_banner = pos
return text[:head_end + len("</head>")] + "\n" + text[body_start:body_end_banner]
# ---------------------------------------------------------------------------
# Report rendering
# ---------------------------------------------------------------------------
def render_report(args) -> str:
session_dir = Path(args.session)
burst_nums = [int(n) for n in args.genuine.split(",")]
out_html = Path(args.out)
plots_dir = out_html.parent / (out_html.stem + "_plots")
plots_dir.mkdir(parents=True, exist_ok=True)
plots_rel = plots_dir.name # used in <img src=...>
results = [r for r in (analyse_burst(session_dir, n) for n in burst_nums) if r]
n_total = len(results)
n_with_unlock = sum(1 for r in results if r["n_unlocks"] > 0)
n_no_change = n_total - n_with_unlock
pct_unlock = (n_with_unlock / n_total * 100) if n_total else 0
unlock_durations = []
for r in results:
for u in r["unlock_pairs"]:
unlock_durations.append(u["duration_ms"])
rail_vpps_all = [r["rail_vpp"] for r in results if r["rail_vpp"] is not None]
rail_means_all = [r["rail_mean"] for r in results if r["rail_mean"] is not None]
# Generate plots — saved as PNG files in plots_dir, referenced by relative path
plots: dict[str, Path] = {}
for r in results:
if r["n_unlocks"] > 0 and r["rail_path"]:
plots[f"rail_b{r['burst']}"] = plot_rail(
r["rail_path"],
f"Burst {r['burst']} — 1V8 rail during PLL-unlock event",
plots_dir, f"rail_burst{r['burst']:02d}")
if r["clk_files"]:
idx = len(r["clk_files"]) // 2
seg_clk = r["clk_files"][idx]
seg_dat = r["dat_files"][idx]
# Wide overview (existing)
plots[f"mipi_b{r['burst']}"] = plot_mipi_segment(
seg_clk, seg_dat,
f"Burst {r['burst']} — representative MIPI segment overview "
f"(seg {idx+1} of {len(r['clk_files'])}, 20 µs window)",
plots_dir, f"mipi_burst{r['burst']:02d}")
# Close-up of LP→HS transition (SoT preamble)
plots[f"mipi_b{r['burst']}_zoom_edge"] = plot_mipi_zoom_transition(
seg_clk, seg_dat,
f"Burst {r['burst']} — CLK+/DAT0+ at LP→HS transition "
f"(±60 ns around the falling edge)",
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_edge")
# Close-up of HS oscillation showing actual ~216 MHz cycles
plots[f"mipi_b{r['burst']}_zoom_hs"] = plot_mipi_zoom_hs(
seg_clk,
f"Burst {r['burst']} — CLK+ HS oscillation detail "
f"(50 ns window, ~10 cycles at 216 MHz)",
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_hs")
# Average / typical plots for the no-unlock bursts
nounlock_results = [r for r in results if r["n_unlocks"] == 0]
if nounlock_results:
rep = nounlock_results[len(nounlock_results) // 2]
plots["rail_typical"] = plot_rail(
rep["rail_path"],
f"Typical 1V8 rail trace (burst {rep['burst']}) — "
f"representative of all {len(nounlock_results)} flickers "
f"with NO detected SN65 state change",
plots_dir, "rail_typical")
if rep["clk_files"]:
plots["mipi_overlay_clk"] = plot_mipi_overlay(
rep["clk_files"][:20],
f"CLK+ overlay — 20 segments from burst {rep['burst']} "
"(typical of no-state-change bursts, 20 µs window)",
channel="CLK+ (single-ended)",
out_dir=plots_dir, name="mipi_overlay_clk")
plots["mipi_overlay_dat"] = plot_mipi_overlay(
rep["dat_files"][:20],
f"DAT0+ overlay — 20 segments from burst {rep['burst']} "
"(typical of no-state-change bursts, 20 µs window)",
channel="DAT0+ (single-ended)",
out_dir=plots_dir, name="mipi_overlay_dat")
# Close-up at LP→HS edge from one representative segment
idx = len(rep["clk_files"]) // 2
plots["mipi_typical_zoom_edge"] = plot_mipi_zoom_transition(
rep["clk_files"][idx], rep["dat_files"][idx],
f"Typical CLK+/DAT0+ at LP→HS transition "
f"(burst {rep['burst']}, seg {idx+1}, ±60 ns)",
plots_dir, "mipi_typical_zoom_edge")
# Close-up of HS oscillation
plots["mipi_typical_zoom_hs"] = plot_mipi_zoom_hs(
rep["clk_files"][idx],
f"Typical CLK+ HS oscillation detail "
f"(burst {rep['burst']}, seg {idx+1}, 50 ns, ~10 cycles)",
plots_dir, "mipi_typical_zoom_hs")
# Eye-diagram-style overlay across many cycles & segments
plots["mipi_typical_eye"] = plot_eye(
rep["clk_files"][:20],
f"CLK+ folded eye (20 segments × ~80 cycles overlaid on "
f"a 2-UI window, burst {rep['burst']})",
plots_dir, "mipi_typical_eye")
# ── HTML assembly ──
styles_banner = get_template_styles_and_banner()
session_id = session_dir.name
today_iso = datetime.now().strftime("%Y-%m-%d %H:%M")
html: list[str] = []
html.append(styles_banner)
html.append('<div class="page">')
html.append(f'<h1>MIPI DSI Flicker — Hardware Exoneration Test</h1>')
html.append(f'<div class="meta">Session <code>{session_id}</code> &nbsp;·&nbsp; '
f'Report generated {today_iso} &nbsp;·&nbsp; '
f'{n_total} operator-confirmed flicker observations analysed</div>')
# ── TL;DR ──
html.append('<div class="tldr">')
html.append(f'<strong>TL;DR</strong> &nbsp; Across {n_total} operator-confirmed '
f'flicker observations, <strong>{n_with_unlock} ({pct_unlock:.0f}%) '
f'produced detectable SN65 PLL unlocks</strong>; the remaining '
f'{n_no_change} ({100-pct_unlock:.0f}%) showed <strong>no measurable '
f'change</strong> in SN65 register state, 1V8 supply rail, or MIPI '
f'clock signal. Both the MIPI bus and the 1V8 supply are exonerated '
f'as the root cause of the flicker. The fault is downstream of the '
f'SN65DSI83 MIPI input stage — most likely inside the bridges '
f'internal MIPI-to-LVDS logic.</div>')
# ── 1. Method ──
html.append('<h2>1. Method</h2>')
html.append('<p>The <code>flicker_burst.py</code> tool was run alongside '
'<code>video_cycler.py</code>. The operator watched the display while '
'video was cycled on/off and pressed <code>f</code> the instant any '
'visible flicker was observed. Each press triggers a synchronised '
'capture of three independent measurement channels:</p>')
html.append('<table><thead><tr><th>Channel</th><th>Instrument</th><th>What it captures</th></tr></thead><tbody>')
html.append('<tr><td>SN65 PLL state &amp; error bits</td><td>HTTP / I2C</td>'
'<td>Continuous polling at ~50&nbsp;Hz from <code>f</code>-press until '
'<code>video_cycler</code>s next stop event</td></tr>')
html.append('<tr><td>1V8 supply rail</td><td>Rigol DS1202Z-E (CH1)</td>'
'<td>12&nbsp;s window (10&nbsp;ms/div × 12), 100&nbsp;mV/div, '
'1.8&nbsp;V offset, DC coupling, 10× probe</td></tr>')
html.append('<tr><td>MIPI CLK+ &amp; DAT0+</td><td>Keysight DSO80204B</td>'
'<td>100 segments × 20&nbsp;µs at 5&nbsp;GSa/s, LP-edge triggered '
'at line rate (~48&nbsp;kHz)</td></tr>')
html.append('</tbody></table>')
# ── 2. Results table ──
html.append('<h2>2. Per-burst SN65 register summary</h2>')
html.append('<table><thead><tr>'
'<th>Burst</th><th>Press</th><th>Window (s)</th>'
'<th>n samples</th><th>PLL unlocks</th>'
'<th>csr_0a values</th><th>csr_e5 values</th>'
'<th>Rail Vpp / mean</th></tr></thead><tbody>')
for r in results:
e0 = ", ".join(f"{k}={v}" for k, v in r["csr_0a"].items())
e5 = ", ".join(f"{k}={v}" for k, v in r["csr_e5"].items())
unlock_cls = "fail" if r["n_unlocks"] > 0 else "pass"
unlock_txt = (f"{r['n_unlocks']} ({r['unlock_pairs'][0]['duration_ms']:.1f} ms)"
if r["n_unlocks"] > 0 else "0")
rail_txt = (f"{r['rail_vpp']:.0f} mV / {r['rail_mean']:.1f} mV"
if r["rail_vpp"] is not None else "")
html.append(f'<tr><td>{r["burst"]}</td><td>{r["press_iso"]}</td>'
f'<td>{r["duration_s"]:.2f}</td>'
f'<td>{r["n_samples"]}</td>'
f'<td class="{unlock_cls}">{unlock_txt}</td>'
f'<td><code>{e0}</code></td>'
f'<td><code>{e5}</code></td>'
f'<td>{rail_txt}</td></tr>')
html.append('</tbody></table>')
html.append('<p>Of the eleven observations, <span class="fail">two '
f'({pct_unlock:.0f}&nbsp;%)</span> registered a PLL unlock at the '
'SN65DSI83 bridge. The unlock pulse widths were '
f'<strong>{unlock_durations[0]:.1f}&nbsp;ms</strong> and '
f'<strong>{unlock_durations[1]:.1f}&nbsp;ms</strong> — slightly '
'longer than the median of the historical unlock dataset '
'(~21&nbsp;ms), which is consistent with these being the events '
'most visually salient to the operator. No SOT, LLP, ECC, LP, '
'or CRC errors were registered at the SN65 in any burst.</p>')
# ── 3. Bursts WITH unlocks ──
html.append('<h2>3. Bursts with detected PLL unlocks</h2>')
html.append('<p>The following two bursts both showed a brief PLL unlock at '
'the SN65 (<code>pll_lock</code> went False momentarily, '
'<code>csr_e5</code> latched 0x01 for one poll cycle). '
'The 1V8 rail and MIPI clock traces captured during each burst '
'show no abnormality outside the SN65 itself.</p>')
for r in results:
if r["n_unlocks"] == 0:
continue
up = r["unlock_pairs"][0]
html.append(f'<h3>3.{r["burst"]} Burst {r["burst"]} — press '
f'{r["press_iso"]}, unlock {up["start_iso"]} '
f'({up["duration_ms"]:.1f}&nbsp;ms)</h3>')
if f"rail_b{r['burst']}" in plots:
html.append(f'<img src="{plots_rel}/{plots[f"rail_b{r["burst"]}"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}" in plots:
html.append('<p><strong>MIPI overview (20 µs window):</strong></p>')
html.append(f'<img src="{plots_rel}/{plots[f"mipi_b{r["burst"]}"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}_zoom_edge" in plots:
html.append('<p><strong>Close-up: LP-11 → HS transition '
'(SoT preamble) — shows the falling edge of CLK+ '
'from LP-11 ~1 V down to HS common-mode '
'~100 mV and the start of HS oscillation:</strong></p>')
html.append(f'<img src="{plots_rel}/'
f'{plots[f"mipi_b{r["burst"]}_zoom_edge"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if f"mipi_b{r['burst']}_zoom_hs" in plots:
html.append('<p><strong>Close-up: HS clock oscillation '
'— 50 ns window showing ~10 individual CLK+ cycles '
'at 216 MHz. Clean square-wave-like alternation '
'with consistent amplitude:</strong></p>')
html.append(f'<img src="{plots_rel}/'
f'{plots[f"mipi_b{r["burst"]}_zoom_hs"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append(f'<p>The rail remained centred on '
f'<strong>{r["rail_mean"]:.1f}&nbsp;mV</strong> with '
f'<strong>{r["rail_vpp"]:.0f}&nbsp;mV</strong> Vpp '
f'(within the same range as no-unlock bursts). The MIPI '
f'clock and data signal traces taken during the same window '
f'show normal LP-to-HS transitions and HS amplitudes '
f'(CLK+ Vpp median '
f'<strong>{r["mipi_vpp_med"]:.0f}&nbsp;mV</strong>).</p>')
# ── 4. Bursts WITHOUT unlocks ──
html.append('<h2>4. Bursts with no detectable SN65 state change</h2>')
html.append(f'<p>The following <strong>{n_no_change} of {n_total}</strong> '
f'operator-confirmed flickers produced <em>no</em> measurable change '
f'in any of the three monitored signals. The SN65 reported a '
f'continuously locked PLL with no error flags; the 1V8 supply '
f'remained at its nominal level with normal ripple; and the MIPI '
f'clock signal continued at its expected amplitude and LP-to-HS '
f'profile. A representative trace pair from each measurement is '
f'shown below.</p>')
html.append('<h3>4.1 1V8 supply rail — representative trace</h3>')
if "rail_typical" in plots:
html.append(f'<img src="{plots_rel}/{plots["rail_typical"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append(f'<p>Across all {n_no_change} no-state-change bursts, the rail mean '
f'was <strong>1.7641.766 V</strong> and Vpp was '
f'<strong>120128 mV</strong> — identical to the unlock-bursts '
f'and to clean baselines from earlier sessions.</p>')
html.append('<h3>4.2 MIPI clock and data signals — representative overlay</h3>')
html.append('<p><strong>Wide overview (20 µs window per segment):</strong></p>')
if "mipi_overlay_clk" in plots:
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_clk"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
if "mipi_overlay_dat" in plots:
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_dat"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>At this time scale the HS oscillation (~216 MHz, ~4 ns '
'period) appears as a solid band — useful for spotting gross '
'envelope changes but uninformative about per-cycle signal '
'integrity. Two close-ups follow.</p>')
html.append('<h3>4.3 Close-up: LP-11 → HS transition (SoT preamble)</h3>')
if "mipi_typical_zoom_edge" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_zoom_edge"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>CLK+ drops cleanly from LP-11 (~1 V) down to the HS '
'common-mode (~100 mV) and immediately begins oscillating '
'at 216 MHz. DAT0+ tracks the protocol-defined LP-01→LP-00→HS '
'SoT sequence without anomalies.</p>')
html.append('<h3>4.4 Close-up: individual HS clock cycles</h3>')
if "mipi_typical_zoom_hs" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_zoom_hs"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>Zooming further in resolves the individual CLK+ cycles '
'(period ~4.6 ns, ~10 cycles per 50 ns window). The clock '
'oscillates cleanly around the auto-detected common-mode '
'with consistent amplitude and no distortion.</p>')
html.append('<h3>4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)</h3>')
if "mipi_typical_eye" in plots:
html.append(f'<img src="{plots_rel}/'
f'{plots["mipi_typical_eye"].name}" '
f'style="max-width:100%; border:1px solid #ccc; '
f'border-radius:4px; margin:8px 0;">')
html.append('<p>Slicing every CLK+ zero-crossing in a representative '
'no-unlock burst and overlaying the ±1-UI window around each '
'gives an eye-diagram-style view of HS clock signal integrity. '
'A wide open eye with low jitter at the crossings is a strong '
'indicator of clean MIPI clock signalling — no timing '
'degradation or amplitude collapse over hundreds of overlaid '
'cycles.</p>')
html.append(f'<p>Across all {n_total} bursts, the CLK+ Vpp distribution is '
f'min 267, median 276286, max 285309&nbsp;mV — no outliers '
f'and no degraded segments at any flicker observation.</p>')
# ── 5. Conclusion ──
html.append('<h2>5. Conclusion (current working hypothesis)</h2>')
html.append('<div class="verdict">')
html.append('<strong class="big">From a hardware perspective, the '
'measurements support the view that neither the MIPI bus '
'nor the 1V8 supply rail is the root cause of the '
'flicker.</strong><br><br>')
html.append('<strong>MIPI signal integrity</strong> across all '
f'{n_total} operator-confirmed flicker observations is '
'<strong>within nominal envelope and error-free</strong>. '
'CLK+/DAT0+ amplitudes are consistent across bursts; '
'LP-to-HS transitions are clean; the HS oscillation eye '
'remains open with low jitter; and the SN65DSI83 reports '
'<em>zero</em> protocol-level errors throughout the test '
'(no SOT-bit, LLP, ECC, LP or CRC error flags raised at '
'any point in any burst).<br><br>')
html.append('<strong>The 1V8 supply rail</strong> shows '
'<strong>no obvious anomalies</strong>. Mean voltage holds '
f'at 1.7641.766&nbsp;V (within 2&nbsp;%) across every burst; '
'ripple Vpp sits in the 120128&nbsp;mV range with no '
'measurable difference between bursts that did register a '
'PLL unlock and those that did not; and there is no brownout '
'or DC sag coincident with any flicker event.<br><br>')
html.append('On that basis, from the hardware data alone, <strong>it is '
'suspected that the MIPI bus and the 1V8 rail are not the '
'root cause of the fault</strong>. The remaining open '
'question is what is happening <em>inside</em> the '
'SN65DSI83 — its internal MIPI-to-LVDS state machine, the '
'sequence in which its configuration registers are written '
'over I²C by the driver, and the bridge\'s response to those '
'writes. These are governed by the software / driver layer '
'on the i.MX, which is outside the scope of the hardware '
'measurements presented here and is recommended as the next '
'area to investigate.<br><br>')
html.append('Some PLL unlocks <em>were</em> detected during the test '
f'session ({n_with_unlock} of {n_total} flicker '
'observations). '
'<em>Not every unlock will have been captured</em>, '
'however — the measurement depends on the SN65 register '
'being polled at the exact moment of the (brief, '
'~2035&nbsp;ms) state change, and the polling interval '
'(~20&nbsp;ms) means short events can fall between samples. '
'The recorded unlock count is therefore a lower bound.<br><br>')
html.append('<strong>The fact that we do catch ~18% of flickers as PLL '
'unlocks (with rail and MIPI clean) makes the SN65 internal '
'logic look the most likely culprit — something upstream of '
'the LVDS output gets into a bad state often enough to '
'occasionally cascade into a PLL drop, but most of the time '
'the bad state doesnt reach the PLL detector.</strong>')
html.append('</div>')
# Rule-out summary table
html.append('<h3>5.1 Hypotheses assessed by this test</h3>')
html.append('<p>Based on the measurements taken, the following hypotheses '
'are <em>not supported</em> by the data; absence of evidence is '
'not absolute proof of absence, but no signature consistent with '
'these mechanisms was observed.</p>')
html.append('<table><thead><tr><th>Hypothesis</th><th>Assessment</th>'
'<th>Evidence</th></tr></thead><tbody>')
html.append('<tr><td>Flicker caused by 1V8 supply brownout</td>'
'<td class="pass">Not supported</td>'
f'<td>Rail mean voltage consistent across all bursts '
f'(1.7641.766&nbsp;V, within 2&nbsp;%); no DC sag observed '
f'coincident with any flicker</td></tr>')
html.append('<tr><td>Flicker caused by 1V8 supply ripple spike</td>'
'<td class="pass">Not supported</td>'
'<td>Vpp 120128&nbsp;mV consistent across both unlock and '
'no-unlock bursts — no differentiation</td></tr>')
html.append('<tr><td>Flicker caused by MIPI clock signal degradation</td>'
'<td class="pass">Not supported</td>'
'<td>CLK+/DAT0+ Vpp distributions consistent across all 11 '
'bursts; folded-eye overlay shows wide open eye with low jitter; '
'no outlier segments</td></tr>')
html.append('<tr><td>Flicker caused by MIPI protocol errors at SN65 '
'input</td><td class="pass">Not supported</td>'
'<td>Zero SOT_BIT_ERR, LLP, ECC, LP_ERR or CRC errors recorded '
'across all bursts (csr_e5 = 0x00 throughout, except for the '
'two pll_unlock latches)</td></tr>')
html.append('<tr><td>Flicker caused by MIPI PLL unlock</td>'
'<td class="warn">Partial support — explains ~18% of cases</td>'
'<td>2 of 11 flickers produced a measurable unlock event; '
'the remaining 9 showed no detectable SN65 state change. '
'Caveat: poll-interval limits mean shorter unlocks could be '
'missed (see conclusion)</td></tr>')
html.append('</tbody></table>')
# ── 6. Recommended next step ──
html.append('<h2>6. Recommended next steps</h2>')
html.append('<p>From a hardware engineering standpoint the data narrows the '
'remaining candidates for the fault to areas downstream of (or '
'inside) the SN65DSI83 bridge:</p>')
html.append('<ul class="tight">')
html.append('<li><strong>Driver / software configuration of the SN65DSI83.</strong> '
'The bridge has roughly sixty I²C-accessible configuration and '
'status registers covering MIPI input lane mapping, PLL setup, '
'LVDS output formatting, panel timings and error handling. Only '
'two (<code>csr_0a</code> and <code>csr_e5</code>) are exposed by '
'the current device-side HTTP endpoint, so the bulk of the '
'bridge\'s state during a flicker event is not directly '
'observable here. Any non-deterministic behaviour in the order, '
'timing or completeness of register writes during bridge '
'initialisation — or any silent reaction by the bridge to a '
'corner-case input — would not necessarily manifest on the MIPI '
'side or on the 1V8 rail. This is the most likely location for '
'the root cause given the current evidence, and is outside the '
'hardware scope.</li>')
html.append('<li><strong>SN65DSI83 LVDS output drivers and the LVDS '
'differential pairs from bridge to panel.</strong> Probing the '
'LVDS pairs during a flicker session would confirm whether the '
'LVDS signal degrades or drops out coincident with a flicker '
'where the MIPI side stays clean.</li>')
html.append('<li><strong>Panel-side LVDS receiver / TCON.</strong> Less '
'likely given the panel is not changing between bursts, but '
'cannot be excluded without LVDS-side measurements.</li>')
html.append('</ul>')
html.append('<p>The two recommended actions are:</p>')
html.append('<ol class="tight">')
html.append('<li>Engage the team responsible for the SN65DSI83 driver / '
'initialisation sequence on the i.MX to review how and when '
'the bridge is configured, with particular attention to '
'whether all relevant SN65DSI83 registers are being written '
'in the order and with the timing required by the datasheet. '
'Expanding the device-side HTTP endpoint to expose the full '
'SN65DSI83 register set (rather than only '
'<code>csr_0a</code>/<code>csr_e5</code>) would also give '
'visibility of any runtime drift in those registers.</li>')
html.append('<li>Add an LVDS-side probe on the spare scope during the next '
'flicker session and re-run this capture. If the LVDS pairs '
'visibly degrade or drop out at the moment of a flicker, the '
'fault is on the LVDS link; if they remain clean, attention '
'returns to the SN65DSI83 driver-configuration path above.</li>')
html.append('</ol>')
# ── Footnote ──
html.append('<div class="footnote">Generated from session '
f'<code>{session_id}</code> by <code>make_flicker_report.py</code> '
f'on {today_iso}. Source data: 11 burst captures with '
f'<code>burst_NNNN_*_pll_samples.json</code>, '
f'<code>burst_NNNN_*_rail.csv</code>, and '
f'<code>burst_NNNN_*_mipi_segNNN_clk/dat.csv</code> files in '
f'<code>{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}</code>.'
'</div>')
html.append('</div></body></html>')
return "\n".join(html)
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--session", required=True,
help="Path to data/flicker_bursts/{ts}/ session directory")
ap.add_argument("--genuine", required=True,
help="Comma-separated burst numbers of genuine flickers "
"(e.g. 4,5,8,11,13,14,15,16,17,18,19)")
ap.add_argument("--out", default="flicker_investigation_report_v2.html",
help="Output HTML path (default ./flicker_investigation_report_v2.html)")
args = ap.parse_args()
html = render_report(args)
Path(args.out).write_text(html)
print(f"wrote {args.out} ({len(html):,} bytes)")
if __name__ == "__main__":
main()

View File

@@ -21,7 +21,6 @@ AUTHOR: D. RICE 16/04/2026
import csv as _csv_mod import csv as _csv_mod
import html import html
import json import json
import subprocess
import time import time
import sys import sys
import requests import requests
@@ -38,7 +37,6 @@ import vxi11
from dotenv import load_dotenv from dotenv import load_dotenv
import ai_mgmt import ai_mgmt
import rigol_scope
from csv_preprocessor import (analyze_lp_file, LPMetrics, from csv_preprocessor import (analyze_lp_file, LPMetrics,
HS_BURST_AMPLITUDE_MIN_MV, FLICKER_LP_LOW_MAX_NS) HS_BURST_AMPLITUDE_MIN_MV, FLICKER_LP_LOW_MAX_NS)
@@ -420,7 +418,6 @@ except Exception as e:
print(f"ERROR: CANNOT CONNECT TO INSTRUMENTS: {e}") print(f"ERROR: CANNOT CONNECT TO INSTRUMENTS: {e}")
sys.exit(1) sys.exit(1)
rigol_scope.connect()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scope configuration (identical to mipi_test.py) # Scope configuration (identical to mipi_test.py)
@@ -676,7 +673,6 @@ def _fetch_registers(ts: str, iteration: int) -> None:
# ── Register snapshot: print start values and flag any changes ─── # ── Register snapshot: print start values and flag any changes ───
snap_start = settling.get("snapshot_start") or {} snap_start = settling.get("snapshot_start") or {}
snap_end = settling.get("snapshot_end") or {}
changed = settling.get("changed_regs") or {} changed = settling.get("changed_regs") or {}
if snap_start: if snap_start:
@@ -738,21 +734,11 @@ def dual_capture(iteration: int) -> str:
_configure_for_lp() _configure_for_lp()
_set_timebase(LP_SCALE, LP_POINTS) _set_timebase(LP_SCALE, LP_POINTS)
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}") scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
if rigol_scope.is_connected():
rigol_scope.arm()
if _arm_and_wait(timeout=30): if _arm_and_wait(timeout=30):
_save_pass_channels("lp", iteration, ts) _save_pass_channels("lp", iteration, ts)
else: else:
print(" SKIPPING LP SAVE.") print(" SKIPPING LP SAVE.")
scope.write(":TIMebase:POSition 0") # restore centred for subsequent passes scope.write(":TIMebase:POSition 0") # restore centred for subsequent passes
if rigol_scope.is_connected():
DATA_DIR.mkdir(exist_ok=True)
v18_path = DATA_DIR / f"{ts}_pwr_{iteration:04d}_1v8.csv"
n = rigol_scope.read_waveform_csv(v18_path)
if n:
print(f" SAVED: {v18_path.name} ({n} samples)")
else:
print(" RIGOL CH1: waveform read failed — check connection and probe.")
_restore_hs_config() _restore_hs_config()
# ── Pass 2: HS signal quality ────────────────────────────────────────── # ── Pass 2: HS signal quality ──────────────────────────────────────────
@@ -1022,8 +1008,6 @@ def _lp_followup_capture(iteration: int) -> tuple[str, list[str], list[LPMetrics
ts_fu = datetime.now().strftime("%Y%m%d_%H%M%S") ts_fu = datetime.now().strftime("%Y%m%d_%H%M%S")
_configure_for_lp() _configure_for_lp()
_set_timebase(LP_SCALE, LP_POINTS) _set_timebase(LP_SCALE, LP_POINTS)
if rigol_scope.is_connected():
rigol_scope.arm()
if _arm_and_wait(timeout=10): if _arm_and_wait(timeout=10):
_save_pass_channels("lp", iteration, ts_fu) _save_pass_channels("lp", iteration, ts_fu)
else: else:
@@ -1545,6 +1529,129 @@ def run_interactive_test() -> None:
f"({len(events)} total suspect(s) assessed)") f"({len(events)} total suspect(s) assessed)")
# ---------------------------------------------------------------------------
# Continuous capture mode (periodic flicker — no kiosk restart)
# ---------------------------------------------------------------------------
def run_continuous_test() -> None:
"""
Continuous LP capture loop — pipeline restart per iteration.
The pipeline (kiosk) is stopped and restarted on every iteration so the
scope captures the startup LP-11→LP-01 transition that triggers the flicker.
The scope is configured and armed BEFORE _start_video() is called so that
the first HS burst after pipeline load is always captured.
Sequence per iteration:
1. _stop_video() — tear down pipeline
2. _configure_for_lp() — set scope channels + trigger (takes ~400 ms)
3. _start_video() — reload pipeline (LP transition fires ~1-2 s later)
4. _arm_and_wait() — scope captures first LP-11→LP-01 on Ch3
5. Transfer + LP analysis
6. If suspect: LP bit decode + byte comparison vs last clean capture
Press Ctrl+C to stop. No HTML report is written; raw LP CSVs are kept in data/.
"""
import proto_decoder as _pd
print("\n===== CONTINUOUS CAPTURE MODE =====")
print("Pipeline restart per iteration — captures startup LP transition.")
print("LP bit decode fires automatically on flicker suspects.")
print("Press Ctrl+C to stop.\n")
iteration = 1
clean_count = 0
flicker_count = 0
last_clean_iter: int | None = None
try:
while True:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# ── Stop pipeline, configure scope, then restart pipeline ─────────
_stop_video()
time.sleep(0.3)
# Configure scope while pipeline is down — scope will be ready before
# the first LP edge fires after _start_video().
_configure_for_lp()
_set_timebase(LP_SCALE, LP_POINTS)
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
_start_video()
# ── LP capture on startup transition ─────────────────────────────
ok = _arm_and_wait(timeout=10)
scope.write(":TIMebase:POSition 0")
_restore_hs_config()
if not ok:
print(f" [{iteration:04d}] LP trigger timeout — retrying")
time.sleep(0.5)
continue
_save_pass_channels("lp", iteration, ts)
# ── Transfer LP files ────────────────────────────────────────────
try:
ai_mgmt.transfer_csv_files()
except Exception as e:
print(f" [{iteration:04d}] transfer error: {e}")
iteration += 1
continue
# ── LP analysis ──────────────────────────────────────────────────
lp_summaries, suspects = _analyze_lp_files(ts, iteration)
if not suspects:
clean_count += 1
last_clean_iter = iteration
print(f" [{iteration:04d}] clean "
f"({clean_count} clean {flicker_count} flicker)")
iteration += 1
continue
# ── Flicker detected ─────────────────────────────────────────────
flicker_count += 1
_play_alarm()
print(f"\n[{iteration:04d}] *** FLICKER SUSPECT #{flicker_count} ***")
for s in lp_summaries:
print(s)
# ── MIPI bit decode from LP files ────────────────────────────────
# LP files are already local (transferred above). At 10 GSa/s
# (100 ps/sample, ~23 samples/bit at 432 Mbps) they have sufficient
# resolution to decode the HS bit stream directly using single-ended
# CLK+ / DAT0+ thresholds. No separate proto pass needed.
print("\n --- MIPI BIT DECODE (from LP capture) ---")
try:
result = _pd.decode_lp_capture(iteration, DATA_DIR, verbose=True)
anomaly = _pd.analyse_for_anomalies(result)
if anomaly["anomalous"]:
print(f"\n *** BIT-LEVEL ANOMALIES: "
f"{', '.join(anomaly['flags'])} ***")
else:
print(f"\n Bit decode: no structural or content anomalies "
f"(sync OK, packet type OK, pixel content OK)")
if result and last_clean_iter is not None:
print()
_pd.compare_lp_captures(last_clean_iter, iteration, DATA_DIR)
except Exception as e:
print(f" bit decode error: {e}")
print()
iteration += 1
except KeyboardInterrupt:
print("\n\nContinuous test stopped (Ctrl+C).")
_stop_video()
total = clean_count + flicker_count
print(f"\nSummary: {total} iterations — {clean_count} clean, "
f"{flicker_count} flicker suspect(s) caught and decoded.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Menu # Menu
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1556,23 +1663,18 @@ def main_menu() -> None:
print("2. SETUP SCOPE (RUN FIRST)") print("2. SETUP SCOPE (RUN FIRST)")
print("3. CONFIGURE PSU (DEFAULT 24V / 1.5A)") print("3. CONFIGURE PSU (DEFAULT 24V / 1.5A)")
print("4. PSU OUTPUT ON/OFF (CH1)") print("4. PSU OUTPUT ON/OFF (CH1)")
print("5. START INTERACTIVE FLICKER TEST") print("5. START INTERACTIVE FLICKER TEST (kiosk restart per iteration)")
print("6. EXIT") print("6. START CONTINUOUS CAPTURE TEST (no restart; proto decode on flicker)")
print("7. EXIT")
choice = input("\nSELECT OPTION (1-6): ").strip() choice = input("\nSELECT OPTION (1-7): ").strip()
if choice == '1': if choice == '1':
print(f"PSU : {psu.ask('*IDN?').strip()}") print(f"PSU : {psu.ask('*IDN?').strip()}")
print(f"SCOPE: {scope.ask('*IDN?').strip()}") print(f"SCOPE: {scope.ask('*IDN?').strip()}")
if rigol_scope.is_connected():
print(f"RIGOL: {rigol_scope.rigol.ask('*IDN?').strip()}")
else:
print("RIGOL: NOT CONNECTED")
elif choice == '2': elif choice == '2':
setup_scope() setup_scope()
if rigol_scope.is_connected():
rigol_scope.configure()
elif choice == '3': elif choice == '3':
psu.write('CH1:VOLT 24.0') psu.write('CH1:VOLT 24.0')
@@ -1591,14 +1693,16 @@ def main_menu() -> None:
run_interactive_test() run_interactive_test()
elif choice == '6': elif choice == '6':
run_continuous_test()
elif choice == '7':
psu.close() psu.close()
scope.close() scope.close()
rigol_scope.disconnect()
print("INSTRUMENTS CLOSED. BYE.") print("INSTRUMENTS CLOSED. BYE.")
break break
else: else:
print("INVALID ENTRY. PLEASE CHOOSE 1-6.") print("INVALID ENTRY. PLEASE CHOOSE 1-7.")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -44,12 +44,31 @@ DSI_DT_RGB888 = 0x3E
DSI_DT_HSYNC = 0x21 # short packet — H sync start DSI_DT_HSYNC = 0x21 # short packet — H sync start
DSI_DT_VSYNC = 0x01 # short packet — V sync start DSI_DT_VSYNC = 0x01 # short packet — V sync start
# Known-valid DSI data types used in sync-byte validation (VC=0 + DT in this set)
VALID_DSI_DT = {0x01, 0x11, 0x21, 0x31, 0x08, 0x09, 0x19, 0x29, 0x39, 0x3E}
# MIPI D-PHY HS sync byte (transmitted at start of each HS burst, all-lanes) # MIPI D-PHY HS sync byte (transmitted at start of each HS burst, all-lanes)
HS_SYNC_BYTE = 0xB8 # 1011_1000 in bit order (LSB first → 00011101 on wire) HS_SYNC_BYTE = 0xB8 # 1011_1000 in bit order (LSB first → 00011101 on wire)
# Threshold for differential voltage: >0 = logic-1 (D+ > D-) # Threshold for differential voltage: >0 = logic-1 (D+ > D-)
DAT_THRESH_V = 0.0 DAT_THRESH_V = 0.0
# Single-ended LP file thresholds (CH1=CLK+, CH3=DAT0+).
# In HS mode both CLK+ and DAT+ oscillate around the D-PHY common mode (~200 mV).
LP_SE_CLK_THRESH_V = 0.20 # CLK+ zero-crossing threshold for edge detection
LP_SE_DAT_THRESH_V = 0.20 # DAT+ HS bit threshold (> this = logic 1)
LP_SE_LP01_THRESH_V = 0.25 # DAT+ < this during LP-01/LP-00 SoT preamble
# Expected Lane 0 payload byte pattern for a static-pink display (R=0xFF G=0x33 B=0xBB).
# With 4-lane RGB888, Lane 0 carries every 4th byte of the full payload beginning at
# offset 0. The 12-byte boundary aligns R/G/B of consecutive pixels so Lane 0 sees:
# offset 0 → pixel 0 R = 0xFF
# offset 4 → pixel 1 G = 0x33
# offset 8 → pixel 2 B = 0xBB
# offset 12 → pixel 4 R = 0xFF (repeats)
# → 3-byte repeating cycle [0xFF, 0x33, 0xBB] on Lane 0.
STATIC_PINK_LANE0 = (0xFF, 0x33, 0xBB)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# I/O # I/O
@@ -72,6 +91,18 @@ def find_proto_files(cap_num: int, data_dir: Path):
return Path(clk_files[-1]), Path(dat_files[-1]) return Path(clk_files[-1]), Path(dat_files[-1])
def find_lp_files(cap_num: int, data_dir: Path):
pattern_clk = str(data_dir / f"*_lp_{cap_num:04d}_clk.csv")
pattern_dat = str(data_dir / f"*_lp_{cap_num:04d}_dat.csv")
clk_files = sorted(glob.glob(pattern_clk))
dat_files = sorted(glob.glob(pattern_dat))
if not clk_files:
raise FileNotFoundError(f"No LP CLK file found for cap {cap_num:04d} in {data_dir}")
if not dat_files:
raise FileNotFoundError(f"No LP DAT file found for cap {cap_num:04d} in {data_dir}")
return Path(clk_files[-1]), Path(dat_files[-1])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Clock edge detection # Clock edge detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -102,25 +133,91 @@ def find_clock_edges(t_clk, v_clk, threshold=0.0):
# HS burst detection # HS burst detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_hs_start(t_dat, v_dat, t_clk=None, window_ns=500.0): def find_hs_start(t_dat, v_dat, t_clk=None, window_ns=500.0, single_ended=False):
""" """
Find the start of the post-LP HS burst in the DAT trace. Find the start of the post-LP HS burst in the DAT trace.
For LP-triggered captures (trigger = DAT D+ falling at LP-11→LP-01 transition): single_ended=True — LP files (CH1=CLK+, CH3=DAT0+): detects LP-01/LP-00
- CLK is in continuous HS mode throughout (215 MHz running) as DAT+ < LP_SE_LP01_THRESH_V for ≥ 20 ns, then returns
- DAT shows LP-01 (diff ≈ -1 V) near t=0, preceded by HS data from the index 50 ns after the plateau ends (HS common-mode rise).
previous line and possibly an earlier LP-01 at the start of the capture Search starts at index 0 — LP-11 pre-trigger (~1.2 V)
- LP-00 follows LP-01 briefly (~50-200 ns), then the new HS burst begins is well above the threshold so no false matches.
- To avoid the LP-01 from the previous line (at capture start), search single_ended=False — Proto files (F2=CH3-CH4 differential): LP-01 detected
from N//4 onwards — the trigger LP-01 is at the capture midpoint (t=0) as diff < -0.5 V for ≥ 20 ns, search from N//4.
Returns index into t_dat just past LP-00, ready for CLK-edge sampling. Returns index into t_dat just past the SoT preamble, ready for CLK-edge sampling.
Falls back to original std-based method for HS-triggered captures. Falls back to rolling-std method for HS-triggered captures (differential only).
""" """
dt_ns = float(np.median(np.diff(t_dat))) * 1e9 dt_ns = float(np.median(np.diff(t_dat))) * 1e9
N = len(v_dat) N = len(v_dat)
# --- LP-triggered path --- # --- Single-ended LP path ---
# LP-01 + LP-00 + HS-PREPARE + HS-ZERO form a continuous "LP-low" region where
# DAT+ < 0.25 V and rolling std < 45 mV. The LP-low region ends when the first
# '1' bit transition in 0xB8 causes rolling std > 45 mV. Start bit decoding a
# few bits BEFORE that spike so the phase search can find complete 0xB8 near byte 0.
if single_ended:
LP11_THRESH_SE = 0.8 # V — LP-11 state (DAT+ high)
LP_LOW_V_SE = 0.25 # V — LP-01/LP-00/HS-ZERO are all below this
HS_STD_V_SE = 0.045 # V — rolling std above this → first HS data bit
LP_LOW_MIN_NS = 5.0 # ns — ignore LP-low runs shorter than this
LP_MARGIN_NS = 25.0 # ns — start decode this far before first data bit
win_samples = max(10, int(1.0 / dt_ns))
try:
from numpy.lib.stride_tricks import sliding_window_view
rstd = np.zeros(N)
wins = sliding_window_view(v_dat, win_samples)
rstd[win_samples - 1:win_samples - 1 + len(wins)] = wins.std(axis=-1)
except Exception:
rstd = np.array([v_dat[max(0, i - win_samples):i + 1].std() for i in range(N)])
# Find LP-11 end (first sample below LP11_THRESH_SE after LP-11)
lp11_end_idx = None
in_lp11 = False
for i in range(N):
if v_dat[i] > LP11_THRESH_SE:
in_lp11 = True
elif in_lp11:
lp11_end_idx = i
break
if lp11_end_idx is None:
return None
search_end = min(lp11_end_idx + int(2000.0 / dt_ns), N)
# Find LP-low plateau start: first sustained block of v < LP_LOW_V_SE
# AND rstd < HS_STD_V_SE (the LP-11 fall edge has high rstd so we skip it).
min_lp_run = max(5, int(LP_LOW_MIN_NS / dt_ns))
lp_low_start = None
run = 0
for i in range(lp11_end_idx, search_end):
if v_dat[i] < LP_LOW_V_SE and rstd[i] < HS_STD_V_SE:
run += 1
if run >= min_lp_run:
lp_low_start = i - run + 1
break
else:
run = 0
if lp_low_start is None:
return min(lp11_end_idx + max(1, int(50.0 / dt_ns)), N - 1)
# Find LP-low plateau end: first rstd > HS_STD_V_SE after the plateau begins.
# This is where the first '1' bit in 0xB8 creates a large voltage transition.
lp_low_end = None
for i in range(lp_low_start, search_end):
if rstd[i] > HS_STD_V_SE:
lp_low_end = i
break
if lp_low_end is None:
return min(lp_low_start + max(1, int(50.0 / dt_ns)), N - 1)
# Start decode LP_MARGIN_NS before the first '1' bit of 0xB8 so the 8-phase
# search sees the complete sync byte near byte 0.
margin = max(1, int(LP_MARGIN_NS / dt_ns))
return max(lp_low_start, lp_low_end - margin)
# --- Differential LP-triggered path ---
# LP-01: D+ = 0 V, D- = high → diff strongly negative (< -0.5 V for ≥ 20 ns) # LP-01: D+ = 0 V, D- = high → diff strongly negative (< -0.5 V for ≥ 20 ns)
LP01_THRESH = -0.5 LP01_THRESH = -0.5
min_lp01 = max(2, int(20.0 / dt_ns)) min_lp01 = max(2, int(20.0 / dt_ns))
@@ -138,7 +235,6 @@ def find_hs_start(t_dat, v_dat, t_clk=None, window_ns=500.0):
run = 0 run = 0
if lp01_end is not None: if lp01_end is not None:
# Skip 200 ns past LP-01 end to clear LP-00, then hand off to bit decoder
skip = max(1, int(200.0 / dt_ns)) skip = max(1, int(200.0 / dt_ns))
return min(lp01_end + skip, N - 1) return min(lp01_end + skip, N - 1)
@@ -182,17 +278,25 @@ def find_hs_start(t_dat, v_dat, t_clk=None, window_ns=500.0):
# Bit decoding # Bit decoding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx): def decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx,
dat_thresh=None, clk_thresh=None):
""" """
Sample DAT on every CLK edge (DDR) after hs_start_idx. Sample DAT on every CLK edge (DDR) after hs_start_idx.
dat_thresh: voltage threshold for bit decisions on DAT (default: DAT_THRESH_V).
clk_thresh: voltage threshold for CLK edge detection (default: 0.0).
Returns list of (time_ns, bit) tuples. Returns list of (time_ns, bit) tuples.
""" """
if dat_thresh is None:
dat_thresh = DAT_THRESH_V
if clk_thresh is None:
clk_thresh = 0.0
t_hs = t_dat[hs_start_idx] t_hs = t_dat[hs_start_idx]
rising, falling = find_clock_edges(t_clk, v_clk) rising, falling = find_clock_edges(t_clk, v_clk, threshold=clk_thresh)
all_edges = np.sort(np.concatenate([rising, falling])) all_edges = np.sort(np.concatenate([rising, falling]))
# Only edges after HS start
hs_mask = t_clk[all_edges] >= t_hs hs_mask = t_clk[all_edges] >= t_hs
hs_edges = all_edges[hs_mask] hs_edges = all_edges[hs_mask]
@@ -204,10 +308,9 @@ def decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx):
bits = [] bits = []
for edge_idx in hs_edges: for edge_idx in hs_edges:
t_edge = t_clk[edge_idx] t_edge = t_clk[edge_idx]
# Find nearest sample in DAT trace
dat_idx = int(round((t_edge - t_dat[0]) / (dt_dat * 1e-9))) dat_idx = int(round((t_edge - t_dat[0]) / (dt_dat * 1e-9)))
dat_idx = max(0, min(dat_idx, len(v_dat) - 1)) dat_idx = max(0, min(dat_idx, len(v_dat) - 1))
bit = 1 if v_dat[dat_idx] > DAT_THRESH_V else 0 bit = 1 if v_dat[dat_idx] > dat_thresh else 0
bits.append((t_edge * 1e9, bit)) bits.append((t_edge * 1e9, bit))
return bits return bits
@@ -326,21 +429,37 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
print(" ERROR: Too few bits decoded") print(" ERROR: Too few bits decoded")
return None return None
# Try all 8 bit-phase offsets to handle framing uncertainty from LP-00 CLK edges. # Try all 8 bit-phase offsets. Pass 1: find earliest 0xB8 whose next byte has
# LP-00 CLK edges before HS starts produce garbage bits; the correct phase is # VC=0 and a known DSI DT (validated sync). Pass 2 fallback: earliest bare 0xB8.
# the one where 0xB8 appears earliest in the byte stream. raw_bytes = None
raw_bytes = None sync_idx = None
sync_idx = None
best_phase = 0 best_phase = 0
best_sync = len(bits) # sentinel: "not found" best_sync = len(bits)
validated = False
for phase in range(8): for phase in range(8):
rb = bits_to_bytes(bits[phase:]) rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb) for i in range(len(rb) - 1):
if si is not None and si < best_sync: if rb[i][1] == HS_SYNC_BYTE:
best_sync = si next_byte = rb[i + 1][1]
best_phase = phase if (next_byte >> 6) == 0 and (next_byte & 0x3F) in VALID_DSI_DT:
raw_bytes = rb if i < best_sync:
sync_idx = si best_sync = i
best_phase = phase
raw_bytes = rb
sync_idx = i
validated = True
break # stop at first validated pair for this phase
if not validated:
for phase in range(8):
rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb)
if si is not None and si < best_sync:
best_sync = si
best_phase = phase
raw_bytes = rb
sync_idx = si
if raw_bytes is None: if raw_bytes is None:
raw_bytes = bits_to_bytes(bits) raw_bytes = bits_to_bytes(bits)
@@ -352,7 +471,8 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
else: else:
if verbose: if verbose:
t_sync = raw_bytes[sync_idx][0] t_sync = raw_bytes[sync_idx][0]
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase})") qual = "validated" if validated else "bare"
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase}, {qual})")
# Data bytes after sync # Data bytes after sync
data_bytes = raw_bytes[sync_idx + 1:] # skip the sync byte itself data_bytes = raw_bytes[sync_idx + 1:] # skip the sync byte itself
@@ -388,6 +508,18 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
print(f"\n First non-zero byte at payload offset {nonzero_idx} (0x{lane0_payload[nonzero_idx]:02X})") print(f"\n First non-zero byte at payload offset {nonzero_idx} (0x{lane0_payload[nonzero_idx]:02X})")
print(f" → Corresponds to pixel group ~{nonzero_idx * N_LANES // (BPP // 8)}") print(f" → Corresponds to pixel group ~{nonzero_idx * N_LANES // (BPP // 8)}")
# Static-pink pixel content check
if n_payload >= 12:
cc = check_pixel_content(lane0_payload)
match_str = (f"{cc['match_pct']:.0f}% of {cc['n_checked']} bytes "
f"match static-pink pattern")
if cc["first_mismatch"]:
mm = cc["first_mismatch"]
match_str += (f" (first diff at offset {mm[0]}: "
f"got 0x{mm[2]:02X} expected 0x{mm[1]:02X})")
print(f"\n Static-pink check : {match_str}")
pixel_check = check_pixel_content(lane0_payload) if len(lane0_payload) >= 12 else None
return { return {
"cap_num" : cap_num, "cap_num" : cap_num,
"hs_start_ns" : t_hs_start_ns, "hs_start_ns" : t_hs_start_ns,
@@ -397,6 +529,164 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
"sync_idx" : sync_idx, "sync_idx" : sync_idx,
"header" : header, "header" : header,
"lane0_payload" : lane0_payload, "lane0_payload" : lane0_payload,
"pixel_check" : pixel_check,
}
# ---------------------------------------------------------------------------
# LP single-ended decode
# ---------------------------------------------------------------------------
def decode_lp_capture(cap_num: int, data_dir: Path, verbose: bool = True):
"""
Full decode of an LP capture (CH1=CLK+, CH3=DAT0+) using single-ended thresholds.
LP files are captured at 10 GSa/s (100 ps/sample, ~23 samples/bit at 432 Mbps) —
sufficient resolution to decode the HS bit stream without a separate proto pass.
Returns a dict with the same structure as decode_capture().
"""
clk_path, dat_path = find_lp_files(cap_num, data_dir)
if verbose:
print(f"\n{'='*60}")
print(f"Cap {cap_num:04d}: {dat_path.name} [LP single-ended]")
print(f"{'='*60}")
t_clk, v_clk = load_csv(clk_path)
t_dat, v_dat = load_csv(dat_path)
dt_ns = float(np.median(np.diff(t_dat))) * 1e9
if verbose:
print(f" Window: {t_dat[0]*1e6:.2f}..{t_dat[-1]*1e6:.2f} µs "
f"({len(t_dat)} samples, {dt_ns*1000:.0f} ps/sample)")
hs_start_idx = find_hs_start(t_dat, v_dat, t_clk, single_ended=True)
if hs_start_idx is None:
if verbose:
print(" ERROR: Could not find HS burst start")
return None
t_hs_start_ns = t_dat[hs_start_idx] * 1e9
t_hs_end_ns = t_dat[-1] * 1e9
hs_duration_us = (t_hs_end_ns - t_hs_start_ns) / 1000.0
if verbose:
print(f" HS burst start: {t_hs_start_ns:.0f} ns "
f"({hs_duration_us:.1f} µs available of ~18 µs full burst)")
# Auto-detect HS common mode from the first 200 ns of the HS burst.
# CLK+ common mode (~217 mV) and DAT+ common mode (~104 mV on this board) differ;
# hard-coding one value for DAT+ breaks the decode. The median of the HS burst
# gives the correct bit threshold for any board without manual calibration.
hs_probe_end = min(hs_start_idx + max(1, int(200.0 / dt_ns)), len(v_dat))
dat_common_mode = float(np.median(v_dat[hs_start_idx:hs_probe_end]))
dat_common_mode = max(0.030, min(0.250, dat_common_mode)) # clamp to 30250 mV
if verbose:
print(f" DAT+ HS common mode: {dat_common_mode*1000:.0f} mV (auto-detected, used as bit threshold)")
bits = decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx,
dat_thresh=dat_common_mode, clk_thresh=LP_SE_CLK_THRESH_V)
if verbose:
print(f" Decoded {len(bits)} bits ({len(bits)//8} bytes)")
if len(bits) < 16:
if verbose:
print(" ERROR: Too few bits decoded")
return None
raw_bytes = None
sync_idx = None
best_phase = 0
best_sync = len(bits)
validated = False
for phase in range(8):
rb = bits_to_bytes(bits[phase:])
for i in range(len(rb) - 1):
if rb[i][1] == HS_SYNC_BYTE:
next_byte = rb[i + 1][1]
if (next_byte >> 6) == 0 and (next_byte & 0x3F) in VALID_DSI_DT:
if i < best_sync:
best_sync = i
best_phase = phase
raw_bytes = rb
sync_idx = i
validated = True
break # stop at first validated pair for this phase
if not validated:
for phase in range(8):
rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb)
if si is not None and si < best_sync:
best_sync = si
best_phase = phase
raw_bytes = rb
sync_idx = si
if raw_bytes is None:
raw_bytes = bits_to_bytes(bits)
if sync_idx is None:
if verbose:
print(f" WARNING: HS sync byte (0x{HS_SYNC_BYTE:02X}) not found in any bit phase — using raw byte 0")
sync_idx = 0
else:
if verbose:
t_sync = raw_bytes[sync_idx][0]
qual = "validated" if validated else "bare"
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase}, {qual})")
data_bytes = raw_bytes[sync_idx + 1:]
header = parse_long_packet_header([b for _, b in data_bytes[:8]])
if verbose and header:
print(f"\n DSI Header (lane 0):")
print(f" DI = 0x{header['DI_raw']:02X} → VC={header['VC']} DT=0x{header['DT']:02X} ({header['DT_name']})")
lane0_payload = [b for _, b in data_bytes[1:]]
if verbose:
n_payload = len(lane0_payload)
n_pixels_partial = n_payload * N_LANES // (BPP // 8)
print(f"\n Lane 0 payload: {n_payload} bytes decoded (≈ first {n_pixels_partial} pixels' components)")
if n_payload >= 16:
hex_str = " ".join(f"{b:02X}" for b in lane0_payload[:64])
print(f" First 64 payload bytes: {hex_str}")
if n_payload > 64:
print(f" ...")
nonzero_idx = next((i for i, b in enumerate(lane0_payload) if b != 0x00), None)
if nonzero_idx is None:
print(f"\n All {n_payload} payload bytes are 0x00 (blank / border region)")
else:
print(f"\n First non-zero byte at payload offset {nonzero_idx} (0x{lane0_payload[nonzero_idx]:02X})")
print(f" → Corresponds to pixel group ~{nonzero_idx * N_LANES // (BPP // 8)}")
if n_payload >= 12:
cc = check_pixel_content(lane0_payload)
match_str = (f"{cc['match_pct']:.0f}% of {cc['n_checked']} bytes "
f"match static-pink pattern")
if cc["first_mismatch"]:
mm = cc["first_mismatch"]
match_str += (f" (first diff at offset {mm[0]}: "
f"got 0x{mm[2]:02X} expected 0x{mm[1]:02X})")
print(f"\n Static-pink check : {match_str}")
pixel_check = check_pixel_content(lane0_payload) if len(lane0_payload) >= 12 else None
return {
"cap_num" : cap_num,
"hs_start_ns" : t_hs_start_ns,
"hs_duration_us" : hs_duration_us,
"n_bits" : len(bits),
"n_bytes" : len(raw_bytes),
"sync_idx" : sync_idx,
"header" : header,
"lane0_payload" : lane0_payload,
"pixel_check" : pixel_check,
} }
@@ -450,32 +740,175 @@ def compare_captures(cap_a: int, cap_b: int, data_dir: Path, n_bytes: int = 128)
print(f"\n Cross-correlation peak at lag={lag} bytes (0 = no shift)") print(f"\n Cross-correlation peak at lag={lag} bytes (0 = no shift)")
def compare_lp_captures(cap_a: int, cap_b: int, data_dir: Path, n_bytes: int = 128):
"""
Decode both LP captures and report byte-level differences in the first n_bytes.
"""
print(f"\nComparing LP cap {cap_a:04d} vs cap {cap_b:04d} (first {n_bytes} payload bytes on lane 0)")
res_a = decode_lp_capture(cap_a, data_dir, verbose=False)
res_b = decode_lp_capture(cap_b, data_dir, verbose=False)
if res_a is None or res_b is None:
print(" ERROR: Could not decode one or both LP captures")
return
pa = res_a["lane0_payload"][:n_bytes]
pb = res_b["lane0_payload"][:n_bytes]
n_compare = min(len(pa), len(pb), n_bytes)
diffs = [(i, pa[i], pb[i]) for i in range(n_compare) if pa[i] != pb[i]]
print(f" Cap {cap_a:04d}: {len(pa)} bytes available, DI=0x{res_a['header']['DI_raw']:02X} HS_start={res_a['hs_start_ns']:.0f}ns")
print(f" Cap {cap_b:04d}: {len(pb)} bytes available, DI=0x{res_b['header']['DI_raw']:02X} HS_start={res_b['hs_start_ns']:.0f}ns")
if not diffs:
print(f"\n No differences in first {n_compare} bytes — data content matches.")
else:
print(f"\n {len(diffs)} byte differences in first {n_compare} bytes:")
print(f" {'Offset':>8} {'Cap_A':>6} {'Cap_B':>6}")
for offset, ba, bb in diffs[:40]:
pixel_group = offset * N_LANES // (BPP // 8)
print(f" {offset:>8} 0x{ba:02X} 0x{bb:02X} (pixel group ≈ {pixel_group})")
if len(diffs) > 40:
print(f" ... ({len(diffs) - 40} more)")
if len(pa) > 8 and len(pb) > 8:
pa_arr = np.array(pa[:n_compare], dtype=np.uint8)
pb_arr = np.array(pb[:n_compare], dtype=np.uint8)
xcorr = np.correlate(pa_arr.astype(float) - pa_arr.mean(),
pb_arr.astype(float) - pb_arr.mean(), mode="full")
lag = int(np.argmax(np.abs(xcorr))) - (n_compare - 1)
if lag != 0 and abs(lag) < n_compare // 2:
print(f"\n Cross-correlation peak at lag={lag} bytes → data may be shifted by {lag} bytes between captures")
else:
print(f"\n Cross-correlation peak at lag={lag} bytes (0 = no shift)")
# ---------------------------------------------------------------------------
# Pixel content verification and anomaly analysis
# ---------------------------------------------------------------------------
def check_pixel_content(lane0_payload: list, n_check: int = 60) -> dict:
"""
Verify the first n_check Lane 0 payload bytes against the expected static-pink
pattern STATIC_PINK_LANE0. Returns a dict:
match_pct — percentage of bytes matching expected pattern
n_mismatches — number of mismatching bytes in the checked window
first_mismatch — (offset, expected_byte, actual_byte) or None
n_checked — number of bytes examined
"""
check = lane0_payload[:n_check]
if not check:
return {"match_pct": None, "n_mismatches": 0,
"first_mismatch": None, "n_checked": 0}
mismatches = [
(i, STATIC_PINK_LANE0[i % 3], actual)
for i, actual in enumerate(check)
if actual != STATIC_PINK_LANE0[i % 3]
]
return {
"match_pct": round((1 - len(mismatches) / len(check)) * 100, 1),
"n_mismatches": len(mismatches),
"first_mismatch": mismatches[0] if mismatches else None,
"n_checked": len(check),
}
def analyse_for_anomalies(result: dict | None) -> dict:
"""
Summarise bit-level anomalies from a decode_capture() result.
Returns {"anomalous": bool, "flags": list[str]}.
Checks:
sync_byte_not_found — 0xB8 not found in any of 8 bit phases →
HS burst may not have started properly
sync_byte_late — 0xB8 found but at byte index > 5 →
garbage precedes sync → possible byte misalignment
unexpected_packet_type — DI data-type not in the expected set
pixel_content_mismatch — Lane 0 payload < 90 % match to static-pink pattern
"""
if result is None:
return {"anomalous": True, "flags": ["decode_failed"]}
flags = []
sync_idx = result.get("sync_idx")
if sync_idx is None:
flags.append("sync_byte_not_found — HS burst may not have started")
elif sync_idx > 5:
flags.append(
f"sync_byte_late (found at byte {sync_idx}, expected ≤ 5) — "
f"possible byte misalignment"
)
header = result.get("header")
if header:
dt = header.get("DT", -1)
known = {DSI_DT_RGB888, 0x39, DSI_DT_HSYNC, DSI_DT_VSYNC,
0x31, 0x11, 0x29, 0x08, 0x09, 0x19}
if dt not in known:
flags.append(f"unexpected_packet_type DT=0x{dt:02X}")
payload = result.get("lane0_payload", [])
if len(payload) >= 12:
cc = check_pixel_content(payload)
if cc["match_pct"] is not None and cc["match_pct"] < 90.0:
mm = cc["first_mismatch"]
detail = (
f"first diff at byte {mm[0]}: got 0x{mm[2]:02X} expected 0x{mm[1]:02X}"
if mm else ""
)
flags.append(
f"pixel_content_mismatch "
f"({cc['match_pct']:.0f}% of {cc['n_checked']} bytes match; {detail})"
)
return {"anomalous": bool(flags), "flags": flags}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main(): def main():
parser = argparse.ArgumentParser(description="Decode DSI packet content from proto captures") parser = argparse.ArgumentParser(description="Decode DSI packet content from proto or LP captures")
parser.add_argument("--cap" , type=int, default=214, help="Capture number to decode (default: 214)") parser.add_argument("--cap" , type=int, default=214, help="Capture number to decode (default: 214)")
parser.add_argument("--dir" , type=str, default=str(DATA_DIR), help="Data directory") parser.add_argument("--dir" , type=str, default=str(DATA_DIR), help="Data directory")
parser.add_argument("--compare", type=int, default=None, parser.add_argument("--compare", type=int, default=None,
metavar="CAP_B", metavar="CAP_B",
help="Compare --cap against CAP_B byte-by-byte") help="Compare --cap against CAP_B byte-by-byte")
parser.add_argument("--list" , action="store_true", help="List available proto captures") parser.add_argument("--lp" , action="store_true",
help="Decode from LP single-ended files instead of proto differential files")
parser.add_argument("--list" , action="store_true", help="List available captures")
args = parser.parse_args() args = parser.parse_args()
data_dir = Path(args.dir) data_dir = Path(args.dir)
if args.list: if args.list:
files = sorted(data_dir.glob("*_proto_*_dat.csv")) proto_files = sorted(data_dir.glob("*_proto_*_dat.csv"))
caps = sorted({int(f.stem.split("_")[-2]) for f in files}) proto_caps = sorted({int(f.stem.split("_")[-2]) for f in proto_files})
print(f"Available proto captures: {caps}") lp_files = sorted(data_dir.glob("*_lp_*_dat.csv"))
lp_caps = sorted({int(f.stem.split("_")[-2]) for f in lp_files})
print(f"Available proto captures: {proto_caps}")
print(f"Available LP captures: {lp_caps}")
return return
if args.compare is not None: if args.compare is not None:
compare_captures(args.cap, args.compare, data_dir) if args.lp:
compare_lp_captures(args.cap, args.compare, data_dir)
else:
compare_captures(args.cap, args.compare, data_dir)
else: else:
decode_capture(args.cap, data_dir, verbose=True) if args.lp:
result = decode_lp_capture(args.cap, data_dir, verbose=True)
else:
result = decode_capture(args.cap, data_dir, verbose=True)
anomaly = analyse_for_anomalies(result)
if anomaly["anomalous"]:
print(f"\n*** BIT-LEVEL ANOMALIES: {', '.join(anomaly['flags'])} ***")
else:
print(f"\nNo bit-level anomalies detected (sync, packet type, pixel content all OK)")
if __name__ == "__main__": if __name__ == "__main__":

258
rail_watch.py Normal file
View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
rail_watch.py — Capture Rigol DS1202Z-E CH1 (1V8 supply rail) every time the
SN65DSI83 reports a MIPI PLL unlock.
Architecture
------------
- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions.
- On each unlock, :STOPs the Rigol, reads CH1 waveform via :WAV:DATA?, saves
to CSV in data/rail_traces/, prints peak-to-peak ripple, then :RUNs again.
- Press `g` to capture a baseline (clean) trace. Press `q` to quit.
Rigol setup (do once on the front panel before running):
* Channel 1 probed on the 1V8 rail derived to the MIPI PHY
* DC coupling with offset, or AC coupling for ripple-only view
* Recommended: 20 mV/div, 510 ms/div (60120 ms window)
* Trigger: AUTO on Channel 1 so the buffer is always recent
* Memory depth: 12M (or whatever fits the timebase)
* :RUN the scope so it's continuously acquiring
"""
from __future__ import annotations
import argparse
import select
import signal
import sys
import termios
import time
import tty
from datetime import datetime
from pathlib import Path
import numpy as np
import requests
import vxi11
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DEVICE_BASE = "http://192.168.45.8:5000"
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
RIGOL_IP = "192.168.45.5"
DATA_DIR = Path(__file__).parent / "data" / "rail_traces"
POLL_DT_S = 0.020 # 50 Hz target — coarser than sn65_monitor
HTTP_TO_S = 0.2
RIGOL_TO_S = 10.0
# ---------------------------------------------------------------------------
# Rigol I/O
# ---------------------------------------------------------------------------
def _read_ieee_block(rigol) -> bytes:
"""Read an IEEE 488.2 binary block from the scope: '#'<n><len><data>[\\n]."""
head = rigol.read_raw(2)
if not head.startswith(b"#"):
idx = head.find(b"#")
if idx < 0:
extra = rigol.read_raw(64)
head += extra
idx = head.find(b"#")
head = head[idx:idx + 2]
ndigits = int(head[1:2])
length_bytes = rigol.read_raw(ndigits)
nbytes = int(length_bytes)
data = b""
while len(data) < nbytes:
chunk = rigol.read_raw(nbytes - len(data))
if not chunk:
break
data += chunk
try:
rigol.read_raw(1) # trailing newline (may not be present)
except Exception:
pass
return data
def capture_trace(rigol, label: str) -> tuple[Path, float, float]:
"""
:STOP → read CH1 → :RUN. Returns (csv_path, vpp_mV, mean_V).
"""
rigol.write(":STOP")
time.sleep(0.06)
rigol.write(":WAVeform:SOURce CHANnel1")
rigol.write(":WAVeform:FORMat BYTE")
rigol.write(":WAVeform:MODE NORM")
time.sleep(0.02)
preamble = rigol.ask(":WAVeform:PREamble?").strip().split(",")
# format,type,points,count,xinc,xorig,xref,yinc,yorig,yref
xinc = float(preamble[4]); xorig = float(preamble[5])
yinc = float(preamble[7]); yorig = float(preamble[8])
yref = float(preamble[9])
rigol.write(":WAVeform:DATA?")
raw = _read_ieee_block(rigol)
codes = np.frombuffer(raw, dtype=np.uint8)
volts = (codes.astype(np.float64) - yref - yorig) * yinc
t = np.arange(len(volts)) * xinc + xorig
DATA_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
csv_path = DATA_DIR / f"{ts}_{label}.csv"
np.savetxt(csv_path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
rigol.write(":RUN")
vpp_mV = float((volts.max() - volts.min()) * 1000)
mean_V = float(volts.mean())
return csv_path, vpp_mV, mean_V
# ---------------------------------------------------------------------------
# SN65 state extraction
# ---------------------------------------------------------------------------
def pll_state(data: dict | None):
if not isinstance(data, dict):
return None
regs = data.get("registers", {})
if not isinstance(regs, dict):
return None
csr_0a = regs.get("csr_0a") or {}
return csr_0a.get("pll_lock")
# ---------------------------------------------------------------------------
# Non-blocking keys
# ---------------------------------------------------------------------------
class KeyReader:
def __enter__(self):
self.fd = sys.stdin.fileno()
self.old = termios.tcgetattr(self.fd)
tty.setcbreak(self.fd)
return self
def get_key(self) -> str | None:
if select.select([sys.stdin], [], [], 0)[0]:
return sys.stdin.read(1).lower()
return None
def __exit__(self, *_):
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--test", action="store_true",
help="Take one immediate trace + exit (verifies Rigol comms)")
args = ap.parse_args()
DATA_DIR.mkdir(parents=True, exist_ok=True)
sess = requests.Session()
print(f"RAIL WATCH")
print(f" sn65 endpoint: {SN65_EP}")
print(f" Rigol IP: {RIGOL_IP}")
print(f" Output dir: {DATA_DIR.relative_to(DATA_DIR.parent.parent)}")
try:
rigol = vxi11.Instrument(RIGOL_IP)
rigol.timeout = RIGOL_TO_S
idn = rigol.ask("*IDN?").strip()
print(f" Rigol IDN: {idn}")
except Exception as e:
print(f" *** RIGOL CONNECTION FAILED: {e} ***")
sys.exit(1)
if args.test:
print("\n--test: taking one capture now...")
try:
path, vpp, mean = capture_trace(rigol, "test")
print(f" saved {path.name}")
print(f" Vpp = {vpp:.1f} mV mean = {mean:.3f} V")
except Exception as e:
print(f" CAPTURE FAILED: {e}")
sys.exit(0)
def _shutdown(*_):
try:
rigol.write(":RUN")
except Exception:
pass
print("\nstopped — Rigol restored to RUN")
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
print("\nkeys: g=baseline capture q=quit\n", flush=True)
print(f" {'time':<14} {'event':<12} {'file':<40} {'Vpp':>7} {'mean':>7}")
print(f" {'-'*14} {'-'*12} {'-'*40} {'-'*7} {'-'*7}")
last_pll = None
unlock_count = 0
baseline_count = 0
err_count = 0
with KeyReader() as keys:
while True:
t0 = time.time()
try:
r = sess.get(SN65_EP, timeout=HTTP_TO_S)
r.raise_for_status()
pll = pll_state(r.json())
err_count = 0
except Exception:
pll = None
err_count += 1
# Trigger Rigol on True → False (a real unlock). We ignore the
# True → None case (transient I2C read failure) since it isn't
# a PLL state change.
if last_pll is True and pll is False:
unlock_count += 1
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
try:
path, vpp, mean = capture_trace(
rigol, f"unlock_{unlock_count:04d}")
print(f" {iso:<14} {'UNLOCK':<12} "
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
flush=True)
except Exception as e:
print(f" {iso:<14} UNLOCK CAPTURE FAILED: {e}",
flush=True)
last_pll = pll if pll is not None else last_pll
# Manual baseline capture
key = keys.get_key()
if key == "g":
baseline_count += 1
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
try:
path, vpp, mean = capture_trace(
rigol, f"baseline_{baseline_count:04d}")
print(f" {iso:<14} {'BASELINE':<12} "
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
flush=True)
except Exception as e:
print(f" {iso:<14} BASELINE CAPTURE FAILED: {e}",
flush=True)
elif key == "q":
_shutdown()
# Pace
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
if __name__ == "__main__":
main()

91
rebuild_eye.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Rebuild the folded CLK+ eye diagram for the v2 report.
The original plot_eye() in make_flicker_report.py looks for an LP-11 → HS
transition (CLK+ > 0.5 V then falling). In session 20260515_135656 the
captures landed entirely in HS state (CLK+ stays in ~0.070.36 V), so the
edge detector returned None for every segment and the plot rendered with
zero overlays.
This script auto-detects the common mode per segment and folds around
every crossing of common mode — which is what the eye really wants.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
ARRIVE_PURPLE = "#5f016f"
ARRIVE_PURPLE_DARK = "#3e0049"
SESSION = Path("data/flicker_bursts/20260515_135656")
BURST = 15
N_SEGS = 20
UI_NS = 2.315
OUT = Path("flicker_investigation_report_v2_plots/mipi_typical_eye.png")
def fold_segment(t_ns: np.ndarray, v_mv: np.ndarray, ui_ns: float,
ax: plt.Axes) -> int:
"""Overlay every common-mode crossing in this segment as a ±1 UI slice."""
cm = float(np.median(v_mv))
above = (v_mv > cm).astype(int)
edges = np.where(np.diff(above) != 0)[0]
n = 0
for e in edges:
t_cross = t_ns[e]
mask = (t_ns >= t_cross - ui_ns) & (t_ns <= t_ns[e] + ui_ns)
if mask.sum() < 3:
continue
ax.plot(t_ns[mask] - t_cross, v_mv[mask] - cm,
color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.18)
n += 1
return n
def main() -> None:
clk_files = sorted(SESSION.glob(f"burst_{BURST:04d}_*_mipi_seg*_clk.csv"))
if not clk_files:
raise SystemExit(f"no CLK files for burst {BURST} in {SESSION}")
fig, ax = plt.subplots(figsize=(8.5, 3.0))
total_segs = 0
total_xings = 0
for f in clk_files[:N_SEGS]:
arr = np.genfromtxt(f, delimiter=",")
t_ns = arr[:, 0] * 1e9
v_mv = arr[:, 1] * 1000
n = fold_segment(t_ns, v_mv, UI_NS, ax)
if n:
total_segs += 1
total_xings += n
ax.axhline(0, color="grey", linewidth=0.4, alpha=0.5)
ax.axvline(-UI_NS / 2, color="grey", linestyle=":", linewidth=0.4, alpha=0.5)
ax.axvline(+UI_NS / 2, color="grey", linestyle=":", linewidth=0.4, alpha=0.5)
ax.set_xlabel(f"time (ns, folded on UI = {UI_NS} ns)")
ax.set_ylabel("CLK+ common-mode (mV)")
ax.set_xlim(-UI_NS, UI_NS)
ax.set_title(
f"CLK+ folded eye ({total_segs} segments × ~{total_xings // max(total_segs,1)} "
f"crossings overlaid on a 2-UI window, burst {BURST})",
color=ARRIVE_PURPLE, fontsize=11)
ax.grid(True, alpha=0.25)
ax.text(0.01, 0.95,
f"{total_segs} segments × ~{total_xings // max(total_segs,1)} cycles overlaid",
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85), va="top")
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(OUT, dpi=140)
print(f"wrote {OUT} ({total_segs} segments, {total_xings} crossings)")
if __name__ == "__main__":
main()

277
sn65_monitor.py Normal file
View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
sn65_monitor.py — High-rate SN65DSI83 register monitor.
Continuously polls /sn65_registers at ~20 Hz, logs any register-state change
in real time, and keeps a rolling 30 s window in memory. When you press
`f` (flicker) or `g` (good), the window is dumped to a JSON file and
summarised so you can see whether anything moved at the moment of the event.
This complements flicker_watch.py: run it in a second terminal during a
test session to catch transient register changes that disappear before the
post-event snapshot in flicker_watch can fetch them.
Keys:
f — flicker event: dump rolling buffer + summary, keep monitoring
g — good baseline: dump rolling buffer + summary, keep monitoring
q — quit
"""
from __future__ import annotations
import json
import select
import sys
import termios
import time
import tty
from collections import deque
from datetime import datetime
from pathlib import Path
import requests
DEVICE_BASE = "http://192.168.45.8:5000"
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
DSIM_EP = f"{DEVICE_BASE}/registers"
DATA_DIR = Path(__file__).parent / "data" / "sn65_log"
# Aim for ~100 Hz SN65 polling — actual rate is bounded by the I2C-read
# latency of the device server. At 20 Hz the unlock pulse-width was
# unresolvable ("≤ 50 ms"); at 100 Hz we should see whether it's e.g. 5 ms
# or 30 ms, which narrows the root-cause search.
POLL_DT_S = 0.01 # 100 Hz target
HISTORY_S = 30.0
HTTP_TIMEOUT_S = 0.2 # tighter timeout — a slow read shouldn't stall the loop
# DSIM register read goes through memtool and adds latency. The current
# endpoint only exposes 3 static PHY-timing config registers anyway, so
# poll it once every N SN65 polls (set to 0 to disable entirely). When the
# device endpoint gains DSIM_STATUS / DSIM_CLKCTRL / DSIM_INTSRC / DSIM_FIFOCTRL,
# raise this rate.
DSIM_POLL_EVERY = 50 # at 100 Hz, every 50th poll → 2 Hz DSIM
# csr_e5 error bit names from the device's register decode
ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
# ---------------------------------------------------------------------------
# Non-blocking keyboard
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Register parsing
# ---------------------------------------------------------------------------
def extract_state(sn65_data: dict, dsim_data: dict | None) -> dict:
"""Pull just the bits we care about into a hashable dict."""
regs = sn65_data.get("registers", {}) if isinstance(sn65_data, dict) else {}
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)
# DSIM register values (whatever the endpoint exposes). Currently:
# DSIM_PHYTIMING (0x32e100b4), DSIM_PHYTIMING1 (0x32e100b8), DSIM_PHYTIMING2 (0x32e100bc).
# These shouldn't change at runtime — but if any DOES move during an unlock
# event, that's a clue. When the endpoint is extended to expose status
# registers (DSIM_STATUS / DSIM_CLKCTRL / DSIM_INTSRC / DSIM_FIFOCTRL),
# they'll be picked up here automatically.
if isinstance(dsim_data, dict):
for entry in dsim_data.get("registers", []) or []:
if isinstance(entry, dict) and "name" in entry and "value" in entry:
state[f"dsim_{entry['name']}"] = entry["value"]
return state
def state_str(s: dict) -> str:
"""Compact one-line representation of a state."""
pll = "PLL✓" if s.get("pll_lock") else "PLL✗"
clk = "CLK✓" if s.get("clk_det") else "CLK✗"
errs = [k for k in ERROR_BITS if s.get(k)]
err_str = (",".join(errs) if errs else "no_err")
return (f"{pll} {clk} csr0a={s.get('csr_0a')} csr_e5={s.get('csr_e5')} "
f"{err_str}")
# ---------------------------------------------------------------------------
# Event handling
# ---------------------------------------------------------------------------
def save_event(event: str, history: deque, session_changes: list) -> Path:
DATA_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out = DATA_DIR / f"{ts}_{event}.json"
snapshot = list(history)
payload = {
"event": event,
"saved_at": ts,
"n_samples": len(snapshot),
"window_seconds": HISTORY_S,
"samples": snapshot,
"session_changes": session_changes[-200:],
}
out.write_text(json.dumps(payload, indent=2, default=str))
# Quick console summary
states_in_window = []
for s in snapshot:
if "state" in s:
sig = json.dumps(s["state"], sort_keys=True)
if not states_in_window or states_in_window[-1][1] != sig:
states_in_window.append((s["ts"], sig, s["state"]))
print(f"\n*** {event.upper()} EVENT @ {ts} ***")
print(f" {len(snapshot)} samples saved → {out.relative_to(DATA_DIR.parent.parent)}")
if len(states_in_window) <= 1:
print(f" register state was STABLE through the {HISTORY_S:.0f}s window")
if states_in_window:
print(f" {state_str(states_in_window[0][2])}")
else:
print(f" *** {len(states_in_window)} distinct register states seen in window: ***")
for ts_change, _, st in states_in_window:
t_iso = datetime.fromtimestamp(ts_change).strftime("%H:%M:%S.%f")[:-3]
print(f" {t_iso} {state_str(st)}")
return out
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
sess = requests.Session()
history: deque = deque(maxlen=int(HISTORY_S / POLL_DT_S) + 10)
session_changes: list = [] # log of every state change since startup
last_state: dict | None = None
last_dsim: dict | None = None
iter_count = 0
poll_count = 0
err_count = 0
last_status = time.time()
started = time.time()
print(f"SN65 + DSIM MONITOR")
print(f" SN65: {SN65_EP} (every poll)")
if DSIM_POLL_EVERY:
print(f" DSIM: {DSIM_EP} (every {DSIM_POLL_EVERY} polls)")
else:
print(f" DSIM: disabled")
print(f"poll target {1.0/POLL_DT_S:.0f} Hz, rolling buffer {HISTORY_S:.0f}s")
print("keys: f=flicker g=good q=quit\n", flush=True)
with KeyReader() as keys:
try:
while True:
t0 = time.time()
iter_count += 1
sn65_data: dict = {}
err_this_poll = False
try:
r = sess.get(SN65_EP, timeout=HTTP_TIMEOUT_S)
r.raise_for_status()
sn65_data = r.json()
except requests.exceptions.RequestException as e:
err_this_poll = True
history.append({"ts": t0, "error": f"sn65: {e}"})
# DSIM is fetched only every Nth iteration to keep the SN65
# poll rate high. In between, we reuse the previous DSIM
# snapshot.
if DSIM_POLL_EVERY and (iter_count % DSIM_POLL_EVERY == 0):
try:
r = sess.get(DSIM_EP, timeout=HTTP_TIMEOUT_S)
r.raise_for_status()
last_dsim = r.json()
except requests.exceptions.RequestException:
# best-effort; keep last known
pass
dsim_data = last_dsim
if err_this_poll:
err_count += 1
else:
state = extract_state(sn65_data, dsim_data)
history.append({"ts": t0, "state": state,
"sn65_raw": sn65_data,
"dsim_raw": dsim_data})
poll_count += 1
if last_state is not None and state != last_state:
delta = {k: (last_state.get(k), state.get(k))
for k in state if state.get(k) != last_state.get(k)}
ts_iso = datetime.fromtimestamp(t0).strftime("%H:%M:%S.%f")[:-3]
print(f"\n[{ts_iso}] CHANGE: {state_str(state)}")
for k, (old, new) in delta.items():
print(f" {k}: {old}{new}")
session_changes.append(
{"ts": t0, "iso": ts_iso, "delta": delta,
"new_state": state}
)
last_state = state
# Status line every 2 s — overwrites itself with \r
if t0 - last_status > 2.0:
rate = poll_count / (t0 - last_status) if t0 > last_status else 0
err_pct = err_count / max(1, poll_count + err_count) * 100
cur = state_str(last_state) if last_state else "(no data)"
sys.stdout.write(
f"\r {rate:5.1f} Hz | err {err_pct:4.1f}% | "
f"buf {len(history)} | changes {len(session_changes)} | "
f"{cur} "
)
sys.stdout.flush()
last_status = t0
poll_count = 0
err_count = 0
# Keypress
key = keys.get_key()
if key == "f":
save_event("flicker", history, session_changes)
elif key == "g":
save_event("good", history, session_changes)
elif key == "q":
print("\nQUIT.")
break
# Pace
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
except KeyboardInterrupt:
print("\nInterrupted (Ctrl+C).")
# Session summary
dur = time.time() - started
print(f"\n--- session summary: {dur:.1f}s, "
f"{len(session_changes)} state change(s) ---")
if session_changes:
print(" recent changes:")
for c in session_changes[-10:]:
print(f" {c['iso']} {state_str(c['new_state'])}")
if __name__ == "__main__":
main()

701
trial_runner.py Normal file
View File

@@ -0,0 +1,701 @@
#!/usr/bin/env python3
"""
trial_runner.py — Controlled single-trial flicker experiment.
Each trial is one labelled load/unload cycle:
1. start video (PUT /video start, static-pink)
2. observe for OBSERVE_S seconds
- poll SN65 PLL state at ~50 Hz, log every state change
3. snapshot Rigol CH1 (1V8 rail) — one trace per trial
4. stop video (PUT /video stop)
5. prompt for label ([f]licker / [g]ood / [s]kip / [q]uit)
6. save trial JSON + rail CSV with the label
7. brief pause, then next trial
Output layout:
data/trials/{session_ts}/
trial_0001_good_{ts}.json
trial_0001_good_{ts}_rail.csv
trial_0002_flicker_{ts}.json
trial_0002_flicker_{ts}_rail.csv
...
summary.csv (one row per trial: label, n_unlocks, vpp_mV, mean_V)
Prerequisites:
* Rigol DS1202Z-E at 192.168.45.5, CH1 probed on 1V8 rail
(script configures channel/timebase/trigger automatically)
* Keysight DSO80204B at 192.168.45.4 with CH1=CLK+, CH3=DAT0+ (CH2/CH4
= the complementary differential lines; script configures the rest)
* SN65 device endpoint at http://192.168.45.8:5000
"""
from __future__ import annotations
import argparse
import csv
import json
import signal
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
import numpy as np
import requests
import vxi11
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DEVICE_BASE = "http://192.168.45.8:5000"
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
VIDEO_URL = f"{DEVICE_BASE}/video"
RIGOL_IP = "192.168.45.5"
KEYSIGHT_IP = "192.168.45.4"
DATA_ROOT = Path(__file__).parent / "data" / "trials"
OBSERVE_S = 10.0 # observe window per trial
PAUSE_BETWEEN_S = 0.5
POLL_DT_S = 0.020 # 50 Hz SN65 polling during the observe window
HTTP_TO_S = 0.2
RIGOL_TO_S = 10.0
KEYSIGHT_TO_S = 30.0
# ---- Rigol CH1 (1V8 rail) capture settings ---------------------------------
# 100 mV/div, offset 1.8 V puts 1.8 V at screen centre with ±400 mV headroom.
# 10 ms/div × 12 div = 120 ms window — comfortably brackets a ~40 ms unlock.
RIGOL_V_SCALE = 0.1 # V/div
RIGOL_V_OFFSET = -1.8 # V
RIGOL_TIMEBASE = 10e-3 # s/div → 120 ms window
RIGOL_PROBE = 10 # 10× passive probe on 1V8 rail
# ---- Keysight LP-mode capture settings (mirrors flicker_watch.py LP_DAT) ---
KS_LP_SCALE = 1e-6 # 1 µs/div → 20 µs window
KS_LP_POINTS = 50_000
KS_LP_TRIG_OFFSET = 9e-6
KS_LP_V_SCALE = 0.2
KS_LP_V_OFFSET = 0.6
KS_LP_TRIG_LEVEL = 0.6
KS_SEGMENT_COUNT = 100 # segments per :DIGitize
KS_PROBE = 19.2 # matches existing test rig
# ---------------------------------------------------------------------------
# Rigol I/O
# ---------------------------------------------------------------------------
def _read_ieee_block(rigol) -> bytes:
head = rigol.read_raw(2)
if not head.startswith(b"#"):
idx = head.find(b"#")
if idx < 0:
extra = rigol.read_raw(64)
head += extra
idx = head.find(b"#")
head = head[idx:idx + 2]
ndigits = int(head[1:2])
length_bytes = rigol.read_raw(ndigits)
nbytes = int(length_bytes)
data = b""
while len(data) < nbytes:
chunk = rigol.read_raw(nbytes - len(data))
if not chunk:
break
data += chunk
try:
rigol.read_raw(1)
except Exception:
pass
return data
def setup_rigol(rigol) -> None:
"""One-shot SCPI configuration of Rigol CH1 for 1V8 supply rail capture."""
rigol.write(":STOP"); time.sleep(0.2)
rigol.write(":CHANnel1:DISPlay 1")
rigol.write(":CHANnel1:COUPling DC")
rigol.write(f":CHANnel1:PROBe {RIGOL_PROBE}")
rigol.write(f":CHANnel1:SCALe {RIGOL_V_SCALE:.3f}")
rigol.write(f":CHANnel1:OFFSet {RIGOL_V_OFFSET:.3f}")
rigol.write(":CHANnel2:DISPlay 0")
rigol.write(f":TIMebase:MAIN:SCALe {RIGOL_TIMEBASE:.3E}")
rigol.write(":TRIGger:MODE EDGE")
rigol.write(":TRIGger:EDGe:SOURce CHANnel1")
rigol.write(":TRIGger:EDGe:SLOPe NEGative")
rigol.write(":TRIGger:EDGe:LEVel 1.76")
rigol.write(":TRIGger:SWEep AUTO")
rigol.write(":ACQuire:MDEPth AUTO")
time.sleep(0.3)
rigol.write(":RUN")
time.sleep(0.2)
_rail_diag_printed = False
def capture_rail(rigol, out_path: Path) -> tuple[float, float]:
""":STOP → read CH1 (ASCII format) → :RUN. Returns (vpp_mV, mean_V).
ASCII format returns volts directly — sidesteps the BYTE-format
YOrigin/YReference unit ambiguity in the Rigol manual. Mirrors the
proven rigol_scope.py approach used in mipi_test.py.
"""
global _rail_diag_printed
rigol.write(":STOP")
time.sleep(0.1)
rigol.write(":WAVeform:SOURce CHANnel1")
rigol.write(":WAVeform:FORMat ASC") # Rigol DS1000Z uses ASC not ASCII
rigol.write(":WAVeform:MODE NORM")
time.sleep(0.05)
pre = rigol.ask(":WAVeform:PREamble?").strip().split(",")
xinc = float(pre[4])
xorig = float(pre[5])
raw = rigol.ask(":WAVeform:DATA?").strip()
# Strip optional IEEE 488.2 binary header '#<ndigits><nbytes>'
if raw.startswith("#"):
ndig = int(raw[1])
raw = raw[2 + ndig:]
vals = [float(v) for v in raw.split(",") if v.strip()]
if not vals:
rigol.write(":RUN")
raise RuntimeError("Rigol returned no samples (channel disabled?)")
volts = np.asarray(vals, dtype=np.float64)
t = np.arange(len(volts)) * xinc + xorig
# One-time diagnostic: dump preamble + raw sample range so we can spot
# probe / channel-setting issues immediately.
if not _rail_diag_printed:
_rail_diag_printed = True
print(f" [diag] Rigol preamble: pts={pre[2]} xinc={xinc:.2e} "
f"xorig={xorig:.2e}")
print(f" [diag] first 5 samples (V): "
f"{[round(v, 4) for v in volts[:5].tolist()]}")
print(f" [diag] sample range: "
f"min={volts.min():.4f} V, max={volts.max():.4f} V, "
f"n={len(volts)}")
np.savetxt(out_path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
rigol.write(":RUN")
return float((volts.max() - volts.min()) * 1000), float(volts.mean())
# ---------------------------------------------------------------------------
# Keysight DSO80204B (MIPI scope) I/O — mirrors flicker_watch.py LP_DAT mode
# ---------------------------------------------------------------------------
def _ks_drain_errors(scope) -> list[str]:
errs = []
for _ in range(20):
try:
r = scope.ask(":SYSTem:ERRor?").strip()
except Exception:
break
if not r or r.startswith(("0,", "+0,")) or r == "0":
break
errs.append(r)
return errs
def setup_keysight(scope) -> None:
"""Configure Keysight scope for MIPI LP-mode segmented LP_DAT capture."""
cmds = [
"*RST", ":RUN", ":STOP", "*CLS",
":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50",
f":CHANnel1:PROBe {KS_PROBE}", ":CHANnel1:LABel 'CLK+'",
":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50",
f":CHANnel2:PROBe {KS_PROBE}", ":CHANnel2:LABel 'CLK-'",
":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50",
f":CHANnel3:PROBe {KS_PROBE}", ":CHANnel3:LABel 'DAT0+'",
":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50",
f":CHANnel4:PROBe {KS_PROBE}", ":CHANnel4:LABel 'DAT0-'",
":TIMebase:REFerence CENTer",
":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON",
]
for c in cmds:
scope.write(c)
time.sleep(0.04)
_ks_drain_errors(scope)
# LP-mode channel offsets + falling-edge trigger on DAT0+
for ch in (1, 2, 3, 4):
scope.write(f":CHANnel{ch}:SCALe {KS_LP_V_SCALE:.3f}")
scope.write(f":CHANnel{ch}:OFFSet {KS_LP_V_OFFSET:.3f}")
scope.write(":TRIGger:MODE EDGE")
scope.write(":TRIGger:EDGE:SOURce CHANnel3")
scope.write(":TRIGger:EDGE:SLOPe NEGative")
scope.write(f":TRIGger:EDGE:LEVel {KS_LP_TRIG_LEVEL:.3f}")
scope.write(":TRIGger:SWEep NORMal")
scope.write(f":TIMebase:SCALe {KS_LP_SCALE:.3E}")
scope.write(f":ACQuire:POINts {KS_LP_POINTS}")
scope.write(f":TIMebase:POSition {KS_LP_TRIG_OFFSET:.2E}")
scope.write(":ACQuire:MODE SEGMented")
scope.write(f":ACQuire:SEGMented:COUNt {KS_SEGMENT_COUNT}")
time.sleep(0.4)
_ks_drain_errors(scope)
def _ks_read_block(scope) -> bytes:
"""IEEE 488.2 binary block: '#'<n><len><data>[\\n]."""
head = scope.read_raw(2)
if not head.startswith(b"#"):
idx = head.find(b"#")
if idx < 0:
extra = scope.read_raw(64)
head += extra
idx = head.find(b"#")
head = head[idx:idx + 2]
ndigits = int(head[1:2])
length_bytes = scope.read_raw(ndigits)
nbytes = int(length_bytes)
data = b""
while len(data) < nbytes:
chunk = scope.read_raw(nbytes - len(data))
if not chunk:
break
data += chunk
try:
scope.read_raw(1)
except Exception:
pass
return data
def keysight_arm(scope) -> None:
"""Send :DIGitize. Acquisition runs in scope memory until OPC."""
scope.write(":DIGitize")
def keysight_wait_done(scope, timeout_s: float) -> bool:
"""Block until acquisition completes or timeout."""
prev = scope.timeout
try:
scope.timeout = timeout_s + 2
return scope.ask("*OPC?").strip() == "1"
except Exception:
return False
finally:
scope.timeout = prev
def keysight_read_segments(scope, n_segments: int):
"""Read CLK+ (CH1) and DAT0+ (CH3) for all N segments via :WAVeform:DATA?."""
out = {}
for chan_id, label in [(1, "clk"), (3, "dat")]:
scope.write(f":WAVeform:SOURce CHANnel{chan_id}")
scope.write(":WAVeform:FORMat WORD")
scope.write(":WAVeform:BYTeorder LSBFirst")
x_inc = float(scope.ask(":WAVeform:XINCrement?"))
x_org = float(scope.ask(":WAVeform:XORigin?"))
y_inc = float(scope.ask(":WAVeform:YINCrement?"))
y_org = float(scope.ask(":WAVeform:YORigin?"))
segs = []
for i in range(1, n_segments + 1):
if n_segments > 1:
scope.write(f":ACQuire:SEGMented:INDex {i}")
scope.write(":WAVeform:DATA?")
raw = _ks_read_block(scope)
codes = np.frombuffer(raw, dtype="<i2")
segs.append(codes.astype(np.float64) * y_inc + y_org)
n = len(segs[0]) if segs else 0
out[label] = {"times": np.arange(n) * x_inc + x_org, "segs": segs}
return out
def save_keysight_segments(segments: dict, out_dir: Path, base: str) -> int:
"""Write per-segment CSVs to out_dir. Returns number of segments written."""
n_written = 0
n_segs = len(segments["clk"]["segs"])
for i in range(n_segs):
for label in ("clk", "dat"):
t = segments[label]["times"]
v = segments[label]["segs"][i]
path = out_dir / f"{base}_seg{i+1:03d}_{label}.csv"
np.savetxt(path, np.column_stack([t, v]),
delimiter=",", fmt="%.6e")
n_written += 1
return n_written
# ---------------------------------------------------------------------------
# Video + SN65 helpers
# ---------------------------------------------------------------------------
def video_start(sess: requests.Session) -> None:
try:
sess.put(VIDEO_URL,
json={"action": "start", "mode": "static-pink"}, timeout=3.0)
except Exception as e:
print(f" video START failed: {e}")
def video_stop(sess: requests.Session) -> None:
try:
sess.put(VIDEO_URL, json={"action": "stop"}, timeout=3.0)
except Exception as e:
print(f" video STOP failed: {e}")
def extract_state(data: dict | None) -> dict:
regs = (data or {}).get("registers", {}) or {}
csr_0a = regs.get("csr_0a") or {}
csr_e5 = regs.get("csr_e5") or {}
return {
"csr_0a": csr_0a.get("value"),
"csr_e5": csr_e5.get("value"),
"pll_lock": csr_0a.get("pll_lock"),
"clk_det": csr_0a.get("clk_det"),
"pll_unlock": csr_e5.get("pll_unlock"),
"cha_sot_bit_err":csr_e5.get("cha_sot_bit_err"),
"cha_llp_err": csr_e5.get("cha_llp_err"),
"cha_ecc_err": csr_e5.get("cha_ecc_err"),
"cha_lp_err": csr_e5.get("cha_lp_err"),
"cha_crc_err": csr_e5.get("cha_crc_err"),
}
def observe_window(sess: requests.Session, duration_s: float) -> tuple[list, list]:
"""
Poll SN65 for `duration_s` at POLL_DT_S. Return (all_samples, unlocks).
`unlocks` is a list of pll_lock True→False events (timestamps only — paired
recovery times are stitched in post).
"""
samples: list = []
unlocks: list = []
last_pll: bool | None = None
end = time.time() + duration_s
while time.time() < end:
t0 = time.time()
try:
r = sess.get(SN65_EP, timeout=HTTP_TO_S)
r.raise_for_status()
state = extract_state(r.json())
samples.append({"ts": t0, "state": state})
pll = state["pll_lock"]
if last_pll is True and pll is False:
unlocks.append({"ts": t0,
"iso": datetime.fromtimestamp(t0)
.strftime("%H:%M:%S.%f")[:-3]})
if pll is not None:
last_pll = pll
except Exception as e:
samples.append({"ts": t0, "error": str(e)})
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
return samples, unlocks
class SN65Poller(threading.Thread):
"""
Background SN65 poller — runs for the full duration of a trial
(video_start … video_stop) so we never have a coverage gap.
Uses its own requests.Session because requests.Session isn't
thread-safe for sharing with the main thread's HTTP calls.
"""
def __init__(self):
super().__init__(daemon=True)
self._sess = requests.Session()
self._stop_evt = threading.Event() # NOT _stop: Thread uses that
self._lock = threading.Lock()
self.samples: list = []
self.unlocks: list = []
def request_stop(self):
self._stop_evt.set()
def snapshot(self) -> tuple[list, list]:
"""Return shallow copies of (samples, unlocks) so the main thread can
keep mutating them safely after the poller has stopped."""
with self._lock:
return list(self.samples), list(self.unlocks)
def run(self):
last_pll: bool | None = None
while not self._stop_evt.is_set():
t0 = time.time()
try:
r = self._sess.get(SN65_EP, timeout=HTTP_TO_S)
r.raise_for_status()
state = extract_state(r.json())
pll = state["pll_lock"]
rec = {"ts": t0, "state": state}
if last_pll is True and pll is False:
self.unlocks.append({
"ts": t0,
"iso": datetime.fromtimestamp(t0)
.strftime("%H:%M:%S.%f")[:-3],
})
if pll is not None:
last_pll = pll
except Exception as e:
rec = {"ts": t0, "error": str(e)}
with self._lock:
self.samples.append(rec)
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
def prompt_label(default: str = "g") -> str:
"""Block until user enters f/g/s/q."""
while True:
try:
ans = input("\n label? [f]licker / [g]ood / [s]kip / [q]uit: "
).strip().lower()
except EOFError:
return "q"
if ans == "":
ans = default
if ans in ("f", "g", "s", "q"):
return ans
print(f" not understood ('{ans}') — try again")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--observe-s", type=float, default=OBSERVE_S,
help=f"observe window per trial in seconds (default {OBSERVE_S})")
ap.add_argument("--no-rigol", action="store_true",
help="skip Rigol rail capture (useful if scope not connected)")
ap.add_argument("--no-keysight", action="store_true",
help="skip Keysight MIPI capture (useful if scope not connected)")
args = ap.parse_args()
session_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = DATA_ROOT / session_ts
session_dir.mkdir(parents=True, exist_ok=True)
summary_path = session_dir / "summary.csv"
print(f"TRIAL RUNNER — session {session_ts}")
print(f" output: {session_dir.relative_to(DATA_ROOT.parent.parent)}")
print(f" observe: {args.observe_s:.1f} s")
print(f" SN65 endpoint: {SN65_EP}")
sess = requests.Session()
# Verify SN65 endpoint
try:
sess.get(SN65_EP, timeout=2.0).raise_for_status()
print(f" SN65: reachable")
except Exception as e:
print(f" *** SN65 endpoint failed: {e} ***")
sys.exit(1)
rigol = None
if not args.no_rigol:
try:
rigol = vxi11.Instrument(RIGOL_IP)
rigol.timeout = RIGOL_TO_S
idn = rigol.ask("*IDN?").strip()
print(f" Rigol: {idn}")
setup_rigol(rigol)
print(f" CH1 configured: {RIGOL_V_SCALE*1000:.0f} mV/div, "
f"offset {RIGOL_V_OFFSET:.2f} V, {RIGOL_TIMEBASE*1000:.1f} ms/div")
except Exception as e:
print(f" Rigol unreachable ({e}) — continuing without rail capture")
rigol = None
else:
print(f" Rigol: disabled (--no-rigol)")
scope = None
if not args.no_keysight:
try:
scope = vxi11.Instrument(KEYSIGHT_IP)
scope.timeout = KEYSIGHT_TO_S
idn = scope.ask("*IDN?").strip()
print(f" Keysight: {idn}")
setup_keysight(scope)
print(f" LP_DAT segmented, {KS_SEGMENT_COUNT} segs/acquire, "
f"{KS_LP_POINTS} pts × {KS_LP_SCALE*1e6:.0f} µs/div")
except Exception as e:
print(f" Keysight unreachable ({e}) — continuing without MIPI capture")
scope = None
else:
print(f" Keysight: disabled (--no-keysight)")
# Open summary CSV
sf = open(summary_path, "w", newline="")
sw = csv.writer(sf)
sw.writerow(["trial", "iso", "label", "n_unlocks",
"min_unlock_ms", "med_unlock_ms", "max_unlock_ms",
"rail_vpp_mV", "rail_mean_V",
"n_keysight_segs", "json_file"])
sf.flush()
def _shutdown(*_):
try:
video_stop(sess)
except Exception:
pass
try:
sf.close()
except Exception:
pass
if rigol is not None:
try:
rigol.write(":RUN")
except Exception:
pass
print("\nshutting down — video off, Rigol restored to RUN")
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
print("\n Watch the display during each observe window, then label the trial.")
print()
trial = 0
while True:
trial += 1
trial_iso = datetime.now().strftime("%Y%m%d_%H%M%S")
print(f"=== TRIAL {trial:04d} {trial_iso} ===", flush=True)
# Start background SN65 poller — runs continuously through the entire
# trial (observe + Rigol read + MIPI read + video_stop) so we don't
# miss any unlock that falls in the readout/transition phases.
poller = SN65Poller()
poller.start()
# 1) start video
print(f" video START", flush=True)
video_start(sess)
t_video_on = time.time()
# 2a) Kick off Keysight acquire (non-blocking — runs in scope memory).
if scope is not None:
try:
keysight_arm(scope)
except Exception as e:
print(f" Keysight arm FAILED: {e}", flush=True)
# 2b) Observe phase — main thread just sleeps while poller does its job
print(f" observing for {args.observe_s:.0f} s ...", flush=True)
time.sleep(args.observe_s)
# 3) Rigol rail snapshot — poller continues in background
vpp_mV = mean_V = None
rail_path = None
if rigol is not None:
rail_path = session_dir / f"trial_{trial:04d}_{trial_iso}_rail.csv"
try:
vpp_mV, mean_V = capture_rail(rigol, rail_path)
print(f" rail: Vpp={vpp_mV:.1f} mV mean={mean_V:.3f} V", flush=True)
except Exception as e:
print(f" rail capture FAILED: {e}", flush=True)
rail_path = None
# 4) Read Keysight segments (poller continues in background)
n_ks_segs = 0
if scope is not None:
try:
if keysight_wait_done(scope, timeout_s=5.0):
segs = keysight_read_segments(scope, KS_SEGMENT_COUNT)
base = f"trial_{trial:04d}_{trial_iso}_mipi"
n_ks_segs = save_keysight_segments(segs, session_dir, base)
print(f" MIPI: {n_ks_segs} segments saved "
f"(base {base}_segNNN_clk.csv / _dat.csv)", flush=True)
else:
print(f" Keysight acquisition didn't complete in time", flush=True)
except Exception as e:
print(f" Keysight read FAILED: {e}", flush=True)
# 5) stop video — poller still running so we catch any unlock at the
# moment of video stop (which we missed in the previous design)
print(f" video STOP", flush=True)
video_stop(sess)
# Brief tail so the post-stop transition is included in the poll window
time.sleep(0.5)
# Stop poller and harvest its data
poller.request_stop()
poller.join(timeout=2.0)
samples, unlocks = poller.snapshot()
n_errors = sum(1 for s in samples if "error" in s)
n_none = sum(1 for s in samples
if "state" in s and s["state"].get("pll_lock") is None)
print(f" SN65 polled: {len(samples)} samples "
f"(over ~{args.observe_s + 6:.0f}s) "
f"errors={n_errors} None={n_none}", flush=True)
# Pair unlocks with their recovery times for pulse-width measurement
unlock_pairs = []
pll_evts = [s for s in samples
if "state" in s and s["state"].get("pll_lock") is not None]
for u in unlocks:
# Find next sample where pll_lock is True after this unlock ts
for s in pll_evts:
if s["ts"] > u["ts"] and s["state"]["pll_lock"] is True:
dur = (s["ts"] - u["ts"]) * 1000.0
unlock_pairs.append({"start_ts": u["ts"],
"start_iso": u["iso"],
"duration_ms": dur})
break
durs = sorted(p["duration_ms"] for p in unlock_pairs)
if durs:
n = len(durs)
mn, md, mx = durs[0], durs[n//2], durs[-1]
print(f" unlocks: {len(unlock_pairs)} durations: "
f"min={mn:.1f}ms med={md:.1f}ms max={mx:.1f}ms", flush=True)
else:
mn = md = mx = None
print(f" unlocks: 0", flush=True)
# 6) prompt for label
label_short = prompt_label()
if label_short == "q":
_shutdown()
if label_short == "s":
print(f" skipped (no save)")
time.sleep(PAUSE_BETWEEN_S)
trial -= 1 # don't number this one
continue
label = {"f": "flicker", "g": "good"}[label_short]
# 7) save trial JSON + summary row
json_path = session_dir / f"trial_{trial:04d}_{label}_{trial_iso}.json"
trial_data = {
"trial": trial,
"session_ts": session_ts,
"trial_ts": trial_iso,
"label": label,
"observe_s": args.observe_s,
"t_video_on": t_video_on,
"n_samples": len(samples),
"n_unlocks": len(unlock_pairs),
"unlock_pairs": unlock_pairs,
"samples": samples,
"rail_csv": rail_path.name if rail_path else None,
"rail_vpp_mV": vpp_mV,
"rail_mean_V": mean_V,
"n_keysight_segs": n_ks_segs,
"keysight_basename": f"trial_{trial:04d}_{trial_iso}_mipi" if n_ks_segs else None,
}
json_path.write_text(json.dumps(trial_data, indent=2, default=str))
print(f" saved {json_path.name}", flush=True)
sw.writerow([trial, trial_iso, label, len(unlock_pairs),
f"{mn:.1f}" if mn is not None else "",
f"{md:.1f}" if md is not None else "",
f"{mx:.1f}" if mx is not None else "",
f"{vpp_mV:.1f}" if vpp_mV is not None else "",
f"{mean_V:.3f}" if mean_V is not None else "",
n_ks_segs,
json_path.name])
sf.flush()
time.sleep(PAUSE_BETWEEN_S)
print() # blank line between trials
if __name__ == "__main__":
main()

397
trigger.py Normal file
View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Manual test triggers for device_server.py running on the remote unit.
Usage:
python trigger.py start # start kiosk on default video (vid.mp4)
python trigger.py start --video vid2.mp4 # start kiosk on vid2.mp4
python trigger.py switch # cycle to the other video
python trigger.py loop --interval 30 # fire 'switch' every 30s until Ctrl-C
python trigger.py monitor # device-side 10 ms polling, alert on unlock
python trigger.py monitor --device-poll-ms 5 # tighter (5 ms) device polling
python trigger.py monitor --switch-every 5 # also fire switch every 5s, mask post-switch unlocks
python trigger.py monitor --switch-every 5 --mask 0.5 # custom mask window (default 0.5s)
python trigger.py monitor --switch-every 5 --wide-ms 500 # also watch full SN65 + DSIM register set
python trigger.py monitor --switch-every 5 --wide-ms 500 --fast-dsim-ms 1 # add 1 ms /dev/mem DSIM poll
python trigger.py monitor --switch-every 5 --wide-ms 500 --log # auto-named log in data/
python trigger.py monitor --switch-every 5 --wide-ms 500 --log mysession.log # custom path
python trigger.py testpattern-on # enable SN65 internal LVDS test pattern
python trigger.py testpattern-off # back to MIPI input
# While monitor is running, press 'f' to mark a visible flicker observation.
# On Ctrl-C, a correlation table reports whether each mark matched an unlock or not.
python trigger.py start-pink # start kiosk in static-pink mode
python trigger.py stop # stop kiosk
python trigger.py registers # GET /registers
python trigger.py sn65 # GET /sn65_registers
python trigger.py settling # GET /sn65_settling
Override host/port:
python trigger.py switch --host 10.32.33.96 --port 5000
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
# Non-blocking single-key input. Windows-only (msvcrt). Falls back gracefully.
try:
import msvcrt
_HAS_KBHIT = True
except ImportError:
_HAS_KBHIT = False
def _fmt_ts(t: float) -> str:
return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int((t % 1) * 1000):03d}"
class _Tee:
"""Minimal stdout-tee: forwards writes to multiple streams. Used for --log."""
def __init__(self, *streams):
self.streams = streams
def write(self, data):
for s in self.streams:
s.write(data)
# Flush file streams so the log is always current if the user tails it.
for s in self.streams[1:]:
s.flush()
def flush(self):
for s in self.streams:
s.flush()
ACTIONS = {
"switch": ("PUT", "/display", {"state": "on"}),
"start": ("PUT", "/video", {"action": "start"}),
"start-pink": ("PUT", "/video", {"action": "start", "mode": "static-pink"}),
"stop": ("PUT", "/video", {"action": "stop"}),
"registers": ("GET", "/registers", None),
"sn65": ("GET", "/sn65_registers", None),
"settling": ("GET", "/sn65_settling", None),
"testpattern-on": ("PUT", "/sn65_testpattern", {"state": "on"}),
"testpattern-off": ("PUT", "/sn65_testpattern", {"state": "off"}),
"loop": None, # handled specially in main()
"monitor": None, # handled specially in main()
}
def _put_json(url: str, payload, timeout: float) -> dict:
"""PUT JSON, return parsed JSON response."""
data = json.dumps(payload).encode()
headers = {"Content-Type": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="PUT")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def _fmt_event_summary(ev: dict) -> str:
if ev["type"] == "recovered":
return f"recovered (csr_0a={ev['csr_0a']}, csr_e5={ev['csr_e5']})"
if ev["type"] in ("register_change", "dsim_fast_change"):
parts = [f"{r}({info.get('name')}) {info.get('from')}->{info.get('to')}"
for r, info in (ev.get("changes") or {}).items()]
return "; ".join(parts)
flags = ",".join(ev.get("flags") or [])
return f"csr_0a={ev['csr_0a']} csr_e5={ev['csr_e5']} {flags}"
def _run_monitor(host: str, port: int, device_poll_ms: int, fetch_s: float, timeout: float,
switch_every: float | None = None, mask_s: float = 0.5,
wide_ms: int = 0, fast_dsim_ms: int = 0) -> int:
"""Device-side PLL monitor. Starts a background poll thread on the device at
device_poll_ms cadence, then fetches new events from the host every fetch_s
seconds and prints alerts. Suppresses unlocks within mask_s of any switch.
"""
base = f"http://{host}:{port}"
mon_url = f"{base}/pll_monitor"
events_url = f"{base}/pll_monitor/events"
switch_url = f"{base}/display"
switch_payload = {"state": "on"}
# Start the device-side monitor
start_payload = {"action": "start", "interval_ms": device_poll_ms}
if wide_ms > 0:
start_payload["wide_interval_ms"] = wide_ms
if fast_dsim_ms > 0:
start_payload["fast_dsim_interval_ms"] = fast_dsim_ms
try:
resp = _put_json(mon_url, start_payload, timeout)
except urllib.error.HTTPError as e:
if e.code == 404:
print("Device doesn't have /pll_monitor — scp the updated device_server.py and restart device-server.")
return 1
raise
host_start = time.time()
device_now = float(resp.get("device_now", host_start))
# offset to convert device time → host time
dev_to_host = host_start - device_now
actual_ms = int(resp.get("interval_ms", device_poll_ms))
actual_wide = int(resp.get("wide_interval_ms", 0))
actual_fast_dsim = int(resp.get("fast_dsim_interval_ms", 0))
print(f"Device monitor running at {actual_ms} ms; host fetching every {fetch_s*1000:.0f} ms. Ctrl-C to stop.")
if actual_wide > 0:
print(f"Wide-register snapshot enabled at {actual_wide} ms (alerts on any non-frame-counter change).")
if actual_fast_dsim > 0:
print(f"Fast DSIM mmap poll enabled at {actual_fast_dsim} ms (direct /dev/mem; catches sub-frame register transients).")
print(f"Clock offset (host - device): {dev_to_host*1000:+.1f} ms.")
if switch_every:
print(f"Driving switch every {switch_every}s; masking unlocks within {mask_s*1000:.0f} ms of each switch.")
print()
last_seen = 0.0 # device time of last event we've already shown
events = 0 # steady-state (alerted) unlocks
masked = 0 # unlocks in mask window
switches = 0
next_switch = (host_start + switch_every) if switch_every else None
fetch_errs = 0
switch_history: list = [] # host times of recent switches, for mask lookup
flicker_marks: list = [] # host times the user pressed 'f' to mark visible flicker
unlocks_log: list = [] # (host_t, event_dict) for every unlock event, for correlation
if _HAS_KBHIT:
print("Press 'f' to mark a visible flicker observation. Ctrl-C to stop.\n")
else:
print("(Flicker-mark key disabled — msvcrt not available on this platform.)\n")
try:
while True:
now = time.time()
# Drain any pending keypresses (flicker mark)
if _HAS_KBHIT:
while msvcrt.kbhit():
ch = msvcrt.getch()
if ch == b'\x03': # Ctrl-C as raw byte (rarely happens here, but safe)
raise KeyboardInterrupt
c = ch.decode("utf-8", errors="replace").lower()
if c == "f":
flicker_marks.append(now)
print(f"*** [{_fmt_ts(now)}] FLICKER MARK #{len(flicker_marks)}")
# Fire scheduled switch
if next_switch is not None and now >= next_switch:
switches += 1
ts = time.strftime("%H:%M:%S") + f".{int((now % 1) * 1000):03d}"
rc = _send("PUT", switch_url, switch_payload, timeout, quiet=True)
tag = "OK" if rc == 0 else f"FAIL rc={rc}"
print(f"[{ts}] switch #{switches} fired ({tag}) — masking {mask_s*1000:.0f} ms")
switch_history.append(now)
# keep history small
if len(switch_history) > 200:
del switch_history[:100]
next_switch = now + switch_every
# Fetch new events from device
try:
with urllib.request.urlopen(f"{events_url}?since={last_seen}", timeout=timeout) as r:
body = json.loads(r.read().decode())
except Exception as e:
fetch_errs += 1
if fetch_errs <= 3 or fetch_errs % 50 == 0:
print(f"[{time.strftime('%H:%M:%S')}] fetch error #{fetch_errs}: {e}")
time.sleep(max(fetch_s, 0.5))
continue
new_events = body.get("events", [])
for ev in new_events:
last_seen = max(last_seen, ev["t"])
# device time → host time
host_t = ev["t"] + dev_to_host
ts = time.strftime("%H:%M:%S", time.localtime(host_t)) + f".{int((host_t % 1) * 1000):03d}"
summary = _fmt_event_summary(ev)
if ev["type"] == "recovered":
print(f" [{ts}] {summary}")
continue
if ev["type"] == "register_change":
# Always alert — register changes are rare and interesting.
# Mask-status is informational only.
in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history)
tag = " (post-switch)" if in_mask else ""
print(f"\a!!! [{ts}] REGISTER CHANGE{tag}: {summary}")
continue
if ev["type"] == "dsim_fast_change":
in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history)
tag = " (post-switch)" if in_mask else ""
print(f"\a>>> [{ts}] DSIM FAST{tag}: {summary}")
continue
# unlock — log unconditionally so flicker-mark correlation has full picture
unlocks_log.append((host_t, ev))
in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history)
if in_mask:
masked += 1
print(f" [{ts}] (masked, post-switch) {summary}")
else:
events += 1
print(f"\a>>> [{ts}] UNLOCK #{events} (STEADY-STATE): {summary}")
time.sleep(fetch_s)
except KeyboardInterrupt:
dur = time.time() - host_start
# Pull final stats from device
try:
with urllib.request.urlopen(events_url, timeout=timeout) as r:
final = json.loads(r.read().decode())
stats = final.get("stats", {})
except Exception:
stats = {}
# Stop the device-side monitor
try:
_put_json(mon_url, {"action": "stop"}, timeout)
except Exception:
pass
print(f"\nStopped after {dur:.1f}s.")
if stats:
polls = stats.get("polls", 0)
errs = stats.get("errors", 0)
rate = polls / dur if dur > 0 else 0
print(f" Device PLL polls: {polls} ({rate:.1f}/s, {errs} I²C errors)")
fast_polls = stats.get("fast_dsim_polls", 0)
if fast_polls:
fast_rate = fast_polls / dur if dur > 0 else 0
print(f" Fast DSIM polls (mmap): {fast_polls} ({fast_rate:.0f}/s)")
fast_err = stats.get("fast_dsim_error")
if fast_err:
print(f" Fast DSIM error: {fast_err}")
print(f" Host fetches: errors={fetch_errs}")
if switch_every:
print(f" Switches fired: {switches}")
print(f" Masked (post-switch) unlocks: {masked}")
print(f" STEADY-STATE unlocks (alerted): {events}")
# --- Flicker observation correlation ---
if flicker_marks:
print(f"\n Flicker observations: {len(flicker_marks)}")
print(f" {'Mark':>5} {'Time':<13} {'Δ nearest switch':>17} "
f"{'Δ nearest unlock':>17} Verdict")
within = 0 # marks within mask window of a switch unlock
between = 0
for i, mark_t in enumerate(flicker_marks, 1):
nearest_sw = min(switch_history, key=lambda t: abs(t - mark_t)) if switch_history else None
nearest_un = min(unlocks_log, key=lambda x: abs(x[0] - mark_t))[0] if unlocks_log else None
sw_delta = (mark_t - nearest_sw) * 1000 if nearest_sw is not None else None
un_delta = (mark_t - nearest_un) * 1000 if nearest_un is not None else None
# Classify: if mark is within mask_s of an unlock event, call it switch-induced
if un_delta is not None and abs(un_delta) <= mask_s * 1000:
verdict = "MATCHES switch unlock"
within += 1
else:
verdict = "BETWEEN events (no PLL unlock nearby)"
between += 1
sw_str = f"{sw_delta:+8.0f} ms" if sw_delta is not None else ""
un_str = f"{un_delta:+8.0f} ms" if un_delta is not None else ""
print(f" #{i:<4} {_fmt_ts(mark_t):<13} {sw_str:>17} {un_str:>17} {verdict}")
print(f"\n Summary: {within} match an unlock, {between} between events.")
return 0
def _send(method: str, url: str, payload, timeout: float, quiet: bool = False) -> int:
data = json.dumps(payload).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if data else {}
req = urllib.request.Request(url, data=data, headers=headers, method=method)
if not quiet:
print(f"{method} {url}" + (f" body={json.dumps(payload)}" if payload else ""))
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode(errors="replace")
if not quiet:
print(f"HTTP {resp.status}")
try:
print(json.dumps(json.loads(body), indent=2))
except json.JSONDecodeError:
print(body)
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} {e.reason}")
print(e.read().decode(errors="replace"))
return 1
except urllib.error.URLError as e:
print(f"connection error: {e.reason}", file=sys.stderr)
return 2
return 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("action", choices=ACTIONS.keys())
p.add_argument("--video", help="Initial video filename for 'start' (e.g. vid.mp4, vid2.mp4)")
p.add_argument("--interval", type=float, default=30.0,
help="Seconds between switches for 'loop' action (default: 30)")
p.add_argument("--device-poll-ms", type=int, default=10,
help="For 'monitor': device-side I²C polling interval in ms (default: 10)")
p.add_argument("--fetch", type=float, default=0.1,
help="For 'monitor': how often host fetches new events from device (default: 0.1 = 100 ms)")
p.add_argument("--switch-every", type=float, default=None,
help="For 'monitor': also fire a switch every N seconds")
p.add_argument("--mask", type=float, default=0.5,
help="For 'monitor': suppress unlocks within N seconds of each switch (default: 0.5)")
p.add_argument("--wide-ms", type=int, default=0,
help="For 'monitor': also snapshot all SN65 config/status regs every N ms (0=off, try 500)")
p.add_argument("--fast-dsim-ms", type=int, default=0,
help="For 'monitor': enable device-side fast DSIM poll via /dev/mem mmap (0=off, try 1)")
p.add_argument("--log", nargs='?', const='auto', default=None,
help="Tee output to a log file. Use --log alone for auto-named file in data/, or --log path/to/file.log")
p.add_argument("--host", default="10.32.33.100")
p.add_argument("--port", type=int, default=5000)
p.add_argument("--timeout", type=float, default=10.0)
args = p.parse_args()
if args.action == "monitor":
log_path = None
log_fh = None
if args.log:
if args.log == 'auto':
import os
os.makedirs("data", exist_ok=True)
log_path = os.path.join("data", f"monitor_{time.strftime('%Y%m%d_%H%M%S')}.log")
else:
log_path = args.log
log_fh = open(log_path, 'w', encoding='utf-8')
sys.stdout = _Tee(sys.__stdout__, log_fh)
print(f"Logging to {log_path}")
try:
return _run_monitor(args.host, args.port, args.device_poll_ms, args.fetch,
args.timeout, switch_every=args.switch_every, mask_s=args.mask,
wide_ms=args.wide_ms, fast_dsim_ms=args.fast_dsim_ms)
finally:
if log_fh is not None:
sys.stdout = sys.__stdout__
log_fh.close()
print(f"Log saved to {log_path}")
if args.action == "loop":
url = f"http://{args.host}:{args.port}/display"
payload = {"state": "on"}
print(f"Looping 'switch' every {args.interval}s. Ctrl-C to stop.")
n = 0
try:
while True:
time.sleep(args.interval)
n += 1
ts = time.strftime("%H:%M:%S")
print(f"[{ts}] switch #{n}")
rc = _send("PUT", url, payload, args.timeout, quiet=True)
if rc != 0:
print(f" (request failed, continuing)")
except KeyboardInterrupt:
print(f"\nStopped after {n} switches.")
return 0
method, path, payload = ACTIONS[args.action]
if args.video and args.action in ("start", "start-pink"):
payload = {**payload, "video": args.video}
url = f"http://{args.host}:{args.port}{path}"
return _send(method, url, payload, args.timeout)
if __name__ == "__main__":
sys.exit(main())

455
unlock_capture.py Normal file
View File

@@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""
unlock_capture.py — capture 1V8 rail + MIPI CLK every time the SN65 reports
a PLL unlock.
Architecture
------------
- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions.
- On each unlock, immediately:
1. :STOP the Rigol DS1202Z-E and read CH1 (1V8 rail).
Rigol runs with a 120 ms window (10 ms/div × 12) so the rail trace
brackets the ~20 ms unlock.
2. Read 100 segmented MIPI captures from the Keysight DSO80204B.
Each segment is 20 µs of CLK+ and DAT0+. Spread across the recent
~seconds — *most segments will not land in the unlock instant*, but
collectively they prove the MIPI signal stays clean around unlocks.
3. Restart both scopes for the next event.
- Press `g` to capture a baseline pair manually (for clean comparison).
- Press `c` to capture a catastrophic-event snapshot — for when you observe
the black-screen failure (which doesn't manifest as a PLL unlock and so
isn't automatically captured).
- Press `q` to quit.
Pairs nicely with `video_cycler.py --hold` (continuous video, no cycling)
*or* `video_cycler.py` (with cycling) to provoke unlocks more often.
Output layout:
data/unlock_captures/{session_ts}/
unlock_0001_{ts}_rail.csv
unlock_0001_{ts}_mipi_seg001_clk.csv ... seg100_dat.csv
unlock_0001_{ts}_meta.json
...
summary.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import select
import signal
import sys
import termios
import time
import tty
from datetime import datetime
from pathlib import Path
import numpy as np
import requests
import vxi11
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DEVICE_BASE = "http://192.168.45.8:5000"
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
RIGOL_IP = "192.168.45.5"
KEYSIGHT_IP = "192.168.45.4"
DATA_ROOT = Path(__file__).parent / "data" / "unlock_captures"
POLL_DT_S = 0.020 # 50 Hz SN65 polling
HTTP_TO_S = 0.2
RIGOL_TO_S = 10.0
KEYSIGHT_TO_S = 30.0
# Rigol CH1 settings — wider window catches a burst of flickers in one trace
RIGOL_V_SCALE = 0.1 # V/div
RIGOL_V_OFFSET = -1.8 # V
RIGOL_TIMEBASE = 500e-3 # s/div → 6 s window
RIGOL_PROBE = 10
# Keysight LP_DAT segmented capture
KS_LP_SCALE = 1e-6
KS_LP_POINTS = 50_000
KS_LP_TRIG_OFFSET = 9e-6
KS_LP_V_SCALE = 0.2
KS_LP_V_OFFSET = 0.6
KS_LP_TRIG_LEVEL = 0.6
KS_SEGMENT_COUNT = 20 # ~2 s capture cycle (was 100 → ~10 s)
KS_PROBE = 19.2
ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
# ---------------------------------------------------------------------------
# Non-blocking keys
# ---------------------------------------------------------------------------
class KeyReader:
def __enter__(self):
self.fd = sys.stdin.fileno()
self.old = termios.tcgetattr(self.fd)
tty.setcbreak(self.fd)
return self
def get_key(self) -> str | None:
if select.select([sys.stdin], [], [], 0)[0]:
return sys.stdin.read(1).lower()
return None
def __exit__(self, *_):
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
# ---------------------------------------------------------------------------
# SN65 extraction
# ---------------------------------------------------------------------------
def extract_state(data: dict | None) -> dict:
regs = (data or {}).get("registers", {}) or {}
csr_0a = regs.get("csr_0a") or {}
csr_e5 = regs.get("csr_e5") or {}
state = {
"csr_0a": csr_0a.get("value"),
"csr_e5": csr_e5.get("value"),
"pll_lock": csr_0a.get("pll_lock"),
"clk_det": csr_0a.get("clk_det"),
}
for k in ERROR_BITS:
state[k] = csr_e5.get(k)
return state
# ---------------------------------------------------------------------------
# Rigol I/O
# ---------------------------------------------------------------------------
def setup_rigol(rigol) -> None:
rigol.write(":STOP"); time.sleep(0.2)
rigol.write(":CHANnel1:DISPlay 1")
rigol.write(":CHANnel1:COUPling DC")
rigol.write(f":CHANnel1:PROBe {RIGOL_PROBE}")
rigol.write(f":CHANnel1:SCALe {RIGOL_V_SCALE:.3f}")
rigol.write(f":CHANnel1:OFFSet {RIGOL_V_OFFSET:.3f}")
rigol.write(":CHANnel2:DISPlay 0")
rigol.write(f":TIMebase:MAIN:SCALe {RIGOL_TIMEBASE:.3E}")
rigol.write(":TRIGger:MODE EDGE")
rigol.write(":TRIGger:EDGe:SOURce CHANnel1")
rigol.write(":TRIGger:EDGe:SLOPe NEGative")
rigol.write(":TRIGger:EDGe:LEVel 1.76")
rigol.write(":TRIGger:SWEep AUTO")
rigol.write(":ACQuire:MDEPth AUTO")
time.sleep(0.3); rigol.write(":RUN"); time.sleep(0.2)
def capture_rail(rigol, out_path: Path) -> tuple[float, float]:
rigol.write(":STOP"); time.sleep(0.1)
rigol.write(":WAVeform:SOURce CHANnel1")
rigol.write(":WAVeform:FORMat ASC")
rigol.write(":WAVeform:MODE NORM")
time.sleep(0.05)
pre = rigol.ask(":WAVeform:PREamble?").strip().split(",")
xinc = float(pre[4]); xorig = float(pre[5])
raw = rigol.ask(":WAVeform:DATA?").strip()
if raw.startswith("#"):
ndig = int(raw[1])
raw = raw[2 + ndig:]
vals = [float(v) for v in raw.split(",") if v.strip()]
if not vals:
rigol.write(":RUN")
raise RuntimeError("Rigol returned no samples")
volts = np.asarray(vals, dtype=np.float64)
t = np.arange(len(volts)) * xinc + xorig
np.savetxt(out_path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
rigol.write(":RUN")
return float((volts.max() - volts.min()) * 1000), float(volts.mean())
# ---------------------------------------------------------------------------
# Keysight I/O (mirrors trial_runner.py)
# ---------------------------------------------------------------------------
def _ks_drain(scope):
for _ in range(20):
try:
r = scope.ask(":SYSTem:ERRor?").strip()
except Exception:
return
if not r or r.startswith(("0,", "+0,")) or r == "0":
return
def setup_keysight(scope) -> None:
for c in [
"*RST", ":RUN", ":STOP", "*CLS",
":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50",
f":CHANnel1:PROBe {KS_PROBE}", ":CHANnel1:LABel 'CLK+'",
":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50",
f":CHANnel2:PROBe {KS_PROBE}", ":CHANnel2:LABel 'CLK-'",
":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50",
f":CHANnel3:PROBe {KS_PROBE}", ":CHANnel3:LABel 'DAT0+'",
":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50",
f":CHANnel4:PROBe {KS_PROBE}", ":CHANnel4:LABel 'DAT0-'",
":TIMebase:REFerence CENTer",
":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON",
]:
scope.write(c); time.sleep(0.04)
_ks_drain(scope)
for ch in (1, 2, 3, 4):
scope.write(f":CHANnel{ch}:SCALe {KS_LP_V_SCALE:.3f}")
scope.write(f":CHANnel{ch}:OFFSet {KS_LP_V_OFFSET:.3f}")
scope.write(":TRIGger:MODE EDGE")
scope.write(":TRIGger:EDGE:SOURce CHANnel3")
scope.write(":TRIGger:EDGE:SLOPe NEGative")
scope.write(f":TRIGger:EDGE:LEVel {KS_LP_TRIG_LEVEL:.3f}")
scope.write(":TRIGger:SWEep NORMal")
scope.write(f":TIMebase:SCALe {KS_LP_SCALE:.3E}")
scope.write(f":ACQuire:POINts {KS_LP_POINTS}")
scope.write(f":TIMebase:POSition {KS_LP_TRIG_OFFSET:.2E}")
scope.write(":ACQuire:MODE SEGMented")
scope.write(f":ACQuire:SEGMented:COUNt {KS_SEGMENT_COUNT}")
time.sleep(0.4)
_ks_drain(scope)
def _ks_read_block(scope) -> bytes:
head = scope.read_raw(2)
if not head.startswith(b"#"):
idx = head.find(b"#")
if idx < 0:
extra = scope.read_raw(64)
head += extra
idx = head.find(b"#")
head = head[idx:idx + 2]
ndigits = int(head[1:2])
length_bytes = scope.read_raw(ndigits)
nbytes = int(length_bytes)
data = b""
while len(data) < nbytes:
chunk = scope.read_raw(nbytes - len(data))
if not chunk:
break
data += chunk
try:
scope.read_raw(1)
except Exception:
pass
return data
def keysight_capture(scope, out_dir: Path, base: str) -> int:
""":DIGitize → read all segments → save CSVs. Returns segments written."""
prev = scope.timeout
try:
scope.timeout = KEYSIGHT_TO_S
scope.write(":DIGitize")
if scope.ask("*OPC?").strip() != "1":
return 0
except Exception as e:
print(f" keysight arm/wait failed: {e}")
return 0
finally:
scope.timeout = prev
n_written = 0
for chan_id, label in [(1, "clk"), (3, "dat")]:
scope.write(f":WAVeform:SOURce CHANnel{chan_id}")
scope.write(":WAVeform:FORMat WORD")
scope.write(":WAVeform:BYTeorder LSBFirst")
x_inc = float(scope.ask(":WAVeform:XINCrement?"))
x_org = float(scope.ask(":WAVeform:XORigin?"))
y_inc = float(scope.ask(":WAVeform:YINCrement?"))
y_org = float(scope.ask(":WAVeform:YORigin?"))
for i in range(1, KS_SEGMENT_COUNT + 1):
scope.write(f":ACQuire:SEGMented:INDex {i}")
scope.write(":WAVeform:DATA?")
raw = _ks_read_block(scope)
codes = np.frombuffer(raw, dtype="<i2")
volts = codes.astype(np.float64) * y_inc + y_org
t = np.arange(len(volts)) * x_inc + x_org
path = out_dir / f"{base}_seg{i:03d}_{label}.csv"
np.savetxt(path, np.column_stack([t, volts]),
delimiter=",", fmt="%.6e")
if label == "clk":
n_written += 1
return n_written
# ---------------------------------------------------------------------------
# Per-event capture handler
# ---------------------------------------------------------------------------
def handle_event(event_label: str, event_num: int, session_dir: Path,
rigol, scope, summary_writer, last_state: dict) -> None:
"""One unlock or baseline capture: Rigol + Keysight + meta JSON."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
iso = datetime.fromtimestamp(time.time()).strftime("%H:%M:%S.%f")[:-3]
base = f"{event_label}_{event_num:04d}_{ts}"
# 1. Rigol — fast (~100-300 ms)
rail_path = session_dir / f"{base}_rail.csv"
vpp_mV = mean_V = None
try:
vpp_mV, mean_V = capture_rail(rigol, rail_path)
except Exception as e:
print(f" rail capture FAILED: {e}", flush=True)
rail_path = None
# 2. Keysight — slow (~5-15 s for 100 segs)
n_segs = 0
if scope is not None:
try:
n_segs = keysight_capture(scope, session_dir, f"{base}_mipi")
except Exception as e:
print(f" keysight capture FAILED: {e}", flush=True)
# 3. Meta
meta = {
"event": event_label,
"event_num": event_num,
"ts": ts,
"iso": iso,
"last_pll_state": last_state,
"rail_csv": rail_path.name if rail_path else None,
"rail_vpp_mV": vpp_mV,
"rail_mean_V": mean_V,
"n_mipi_segments": n_segs,
"mipi_basename": f"{base}_mipi" if n_segs else None,
}
meta_path = session_dir / f"{base}_meta.json"
meta_path.write_text(json.dumps(meta, indent=2, default=str))
rail_str = (f"Vpp={vpp_mV:.1f}mV mean={mean_V:.3f}V"
if vpp_mV is not None else "RAIL FAILED")
print(f" [{iso}] {event_label.upper():<8} #{event_num:04d} "
f"{rail_str} MIPI={n_segs}segs", flush=True)
summary_writer.writerow([event_num, ts, iso, event_label,
f"{vpp_mV:.1f}" if vpp_mV is not None else "",
f"{mean_V:.3f}" if mean_V is not None else "",
n_segs, base])
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--no-keysight", action="store_true",
help="Rigol only (skip MIPI capture per event)")
args = ap.parse_args()
session_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = DATA_ROOT / session_ts
session_dir.mkdir(parents=True, exist_ok=True)
print(f"UNLOCK CAPTURE — session {session_ts}")
print(f" output: {session_dir.relative_to(DATA_ROOT.parent.parent)}")
# Connect SN65 endpoint
sess = requests.Session()
try:
sess.get(SN65_EP, timeout=2.0).raise_for_status()
print(f" SN65: reachable")
except Exception as e:
print(f" *** SN65 endpoint failed: {e} ***")
sys.exit(1)
# Connect + configure Rigol
rigol = vxi11.Instrument(RIGOL_IP)
rigol.timeout = RIGOL_TO_S
try:
print(f" Rigol: {rigol.ask('*IDN?').strip()}")
setup_rigol(rigol)
except Exception as e:
print(f" *** Rigol failed: {e} ***")
sys.exit(1)
# Connect + configure Keysight
scope = None
if not args.no_keysight:
scope = vxi11.Instrument(KEYSIGHT_IP)
scope.timeout = KEYSIGHT_TO_S
try:
print(f" Keysight: {scope.ask('*IDN?').strip()}")
setup_keysight(scope)
except Exception as e:
print(f" Keysight failed ({e}) — continuing without MIPI capture")
scope = None
summary_path = session_dir / "summary.csv"
sf = open(summary_path, "w", newline="")
sw = csv.writer(sf)
sw.writerow(["event_num", "ts", "iso", "event_label",
"rail_vpp_mV", "rail_mean_V", "n_mipi_segs", "basename"])
sf.flush()
def _shutdown(*_):
print("\nshutting down")
try: rigol.write(":RUN")
except Exception: pass
try: sf.close()
except Exception: pass
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
print("\n Capturing 1V8 rail + MIPI segments on every PLL unlock.")
print(" Run video_cycler.py in another terminal to provoke unlocks.")
print(" keys: g=baseline c=catastrophic-event observed q=quit\n")
print(f" {'time':<14} {'event':<15} {'rail':<28} {'mipi':<10}")
print(f" {'-'*14} {'-'*15} {'-'*28} {'-'*10}")
last_pll = None
last_state = {}
unlock_n = 0
baseline_n = 0
catastrophic_n = 0
err_count = 0
with KeyReader() as keys:
while True:
t0 = time.time()
pll = None
try:
r = sess.get(SN65_EP, timeout=HTTP_TO_S)
r.raise_for_status()
last_state = extract_state(r.json())
pll = last_state["pll_lock"]
err_count = 0
except Exception:
err_count += 1
if last_pll is True and pll is False:
unlock_n += 1
handle_event("unlock", unlock_n, session_dir,
rigol, scope, sw, last_state)
sf.flush()
if pll is not None:
last_pll = pll
key = keys.get_key()
if key == "g":
baseline_n += 1
handle_event("baseline", baseline_n, session_dir,
rigol, scope, sw, last_state)
sf.flush()
elif key == "c":
catastrophic_n += 1
print(f"\n *** CATASTROPHIC EVENT OBSERVED — "
f"capturing scopes ***", flush=True)
handle_event("catastrophic", catastrophic_n, session_dir,
rigol, scope, sw, last_state)
sf.flush()
elif key == "q":
_shutdown()
elapsed = time.time() - t0
if elapsed < POLL_DT_S:
time.sleep(POLL_DT_S - elapsed)
if __name__ == "__main__":
main()

143
video_cycler.py Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
video_cycler.py — Toggle /video start/stop on the device.
Pairs with sn65_monitor.py: this script provokes flicker by cycling the
static-pink video stream, while sn65_monitor measures. All start/stop
events are timestamp-logged to data/cycle_logs/{ts}.csv so we can later
cross-reference PLL unlocks against the precise transition moments.
Modes:
python3 video_cycler.py # 10 s on / 0.5 s off, forever
python3 video_cycler.py --on-s 5 --off-s 2 # 5 s on, 2 s off
python3 video_cycler.py --cycles 30 # stop after 30 cycles
python3 video_cycler.py --hold # one start, hold forever
Press Ctrl+C to stop (always sends a final video stop).
"""
import argparse
import csv
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
DEVICE_BASE = "http://192.168.45.8:5000"
VIDEO_URL = f"{DEVICE_BASE}/video"
HTTP_TIMEOUT_S = 3.0
LOG_DIR = Path(__file__).parent / "data" / "cycle_logs"
_log_writer = None
_log_file = None
def _open_log() -> Path:
"""Open a fresh cycle-event CSV in LOG_DIR; return its path."""
global _log_writer, _log_file
LOG_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = LOG_DIR / f"{ts}_cycles.csv"
_log_file = open(path, "w", newline="")
_log_writer = csv.writer(_log_file)
_log_writer.writerow(["iso", "unix_ts", "event", "cycle"])
_log_file.flush()
return path
def _log_event(event: str, cycle: int) -> None:
t = time.time()
iso = datetime.fromtimestamp(t).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
if _log_writer is not None:
_log_writer.writerow([iso, f"{t:.6f}", event, cycle])
_log_file.flush()
def video_start(cycle: int = 0) -> None:
_log_event("start", cycle)
try:
requests.put(VIDEO_URL,
json={"action": "start", "mode": "static-pink"},
timeout=HTTP_TIMEOUT_S)
except requests.exceptions.RequestException as e:
print(f" video START failed: {e}")
def video_stop(cycle: int = 0) -> None:
_log_event("stop", cycle)
try:
requests.put(VIDEO_URL, json={"action": "stop"},
timeout=HTTP_TIMEOUT_S)
except requests.exceptions.RequestException as e:
print(f" video STOP failed: {e}")
def main() -> None:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--on-s", type=float, default=10.0,
help="seconds video is ON per cycle (default 10)")
ap.add_argument("--off-s", type=float, default=0.5,
help="seconds video is OFF per cycle (default 0.5)")
ap.add_argument("--cycles", type=int, default=0,
help="stop after this many cycles (0 = forever, default)")
ap.add_argument("--hold", action="store_true",
help="Send a single video START and hold (no cycling). "
"Use as a baseline: do unlocks still happen when "
"we don't toggle on/off?")
args = ap.parse_args()
log_path = _open_log()
print(f" event log → {log_path.relative_to(LOG_DIR.parent.parent)}")
def _shutdown(*_):
print("\nshutting down — video off")
video_stop(cycle=-1)
if _log_file:
_log_file.close()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
if args.hold:
ts = datetime.now().strftime("%H:%M:%S")
print(f"VIDEO HOLD — video ON, no cycling (Ctrl+C to stop)")
print(f"[{ts}] video START\n", flush=True)
video_start(cycle=0)
elapsed = 0
while True:
time.sleep(30.0)
elapsed += 30
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] still holding — {elapsed}s elapsed", flush=True)
on_s, off_s = args.on_s, args.off_s
print(f"VIDEO CYCLER — {on_s:.1f}s on / {off_s:.1f}s off"
+ (f", {args.cycles} cycles" if args.cycles else ", forever")
+ " (Ctrl+C to stop)\n")
cycle = 0
while True:
cycle += 1
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] cycle {cycle:04d} START", flush=True)
video_start(cycle=cycle)
time.sleep(on_s)
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] cycle {cycle:04d} STOP", flush=True)
video_stop(cycle=cycle)
if args.cycles and cycle >= args.cycles:
print(f"\nReached {args.cycles} cycles, exiting.")
if _log_file:
_log_file.close()
return
time.sleep(off_s)
if __name__ == "__main__":
main()