Compare commits
9 Commits
2892ea45ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4400914f | ||
|
|
0f7b0e1ac5 | ||
|
|
423766f7a3 | ||
|
|
39f4355b8d | ||
|
|
d73aa2f2a4 | ||
|
|
8d8df1e7a7 | ||
|
|
75248c9574 | ||
|
|
dd93fbd893 | ||
|
|
9c75598728 |
13
.claude/settings.local.json
Normal 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
BIN
__pycache__/explode_h5.cpython-312.pyc
Normal file
183
analyze_session.py
Normal 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()
|
||||
BIN
arrive-logotype-purple-RGB.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
321
compare_stops.py
Normal 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()
|
||||
@@ -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)
|
||||
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:
|
||||
raise ValueError(f"Filename does not match lp pattern: {path.name}")
|
||||
|
||||
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)
|
||||
dt = float(np.diff(times).mean())
|
||||
|
||||
14
cycle.sh
Normal 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
@@ -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
|
||||
462
device_server.py
@@ -8,9 +8,11 @@ Provides:
|
||||
Add addresses to REGISTER_COMMANDS to capture more register ranges.
|
||||
"""
|
||||
|
||||
import mmap
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -22,7 +24,7 @@ app = Flask(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_lock = threading.Lock()
|
||||
@@ -82,8 +84,8 @@ _SN65_SNAPSHOT_REGS: dict[int, str] = {
|
||||
0x2A: "HFP", # CHA horizontal front porch
|
||||
0x2C: "VFP", # CHA vertical front porch
|
||||
# Format / output
|
||||
0x2D: "TEST_PATTERN", # bit0 = enable colour bar test pattern
|
||||
0x3C: "LVDS_FORMAT", # LVDS output format (colour depth, channel swap)
|
||||
0x2D: "REG_0x2D", # unknown — was mislabeled "TEST_PATTERN" but isn't
|
||||
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
|
||||
0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live]
|
||||
0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live]
|
||||
@@ -144,6 +146,13 @@ _DSIM_NAMES = {
|
||||
0x32e1000c: "DSIM_TIMEOUT",
|
||||
0x32e10010: "DSIM_CONFIG",
|
||||
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",
|
||||
0x32e100b0: "DSIM_PHYACCHR1",
|
||||
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)
|
||||
|
||||
|
||||
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"])
|
||||
def get_sn65_settling():
|
||||
"""Return the most recent post-restart settling poll.
|
||||
@@ -356,6 +430,385 @@ def get_sn65_registers():
|
||||
}), 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"])
|
||||
def control_video():
|
||||
"""Start or stop the kiosk video player.
|
||||
@@ -375,6 +828,9 @@ def control_video():
|
||||
mode = data.get("mode", "")
|
||||
if mode == "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
|
||||
log = open("/tmp/kiosk.log", "w")
|
||||
_video_proc = subprocess.Popen(
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import argparse
|
||||
import gi
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import os
|
||||
import sys
|
||||
|
||||
gi.require_version('Gst', '1.0')
|
||||
from gi.repository import Gst, GLib
|
||||
|
||||
SWITCH_UDP_PORT = 5001
|
||||
|
||||
class KioskManager:
|
||||
def __init__(self, pipeline):
|
||||
self.pipeline = pipeline
|
||||
self.videos = [
|
||||
"file:///root/vid.mp4",
|
||||
"file:///root/vid2.mp4"
|
||||
"file:///root/python/vid.mp4",
|
||||
"file:///root/python/vid2.mp4"
|
||||
]
|
||||
self.current_video_index = 0
|
||||
|
||||
@@ -117,7 +120,32 @@ def handle_button(source, condition, manager):
|
||||
|
||||
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)
|
||||
|
||||
pipeline = Gst.ElementFactory.make("playbin", "player")
|
||||
@@ -129,17 +157,19 @@ def play_kiosk():
|
||||
pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink"))
|
||||
|
||||
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)
|
||||
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(manager.switch_video)
|
||||
|
||||
threading.Thread(target=_udp_listener, daemon=True).start()
|
||||
# --- UDP SWITCH LISTENER ---
|
||||
# device_server.py sends b'switch' to 127.0.0.1:5001 to cycle videos remotely.
|
||||
try:
|
||||
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp_sock.setblocking(False)
|
||||
udp_sock.bind(("127.0.0.1", SWITCH_UDP_PORT))
|
||||
GLib.io_add_watch(udp_sock, GLib.IO_IN, handle_udp_switch, manager)
|
||||
except Exception as e:
|
||||
print(f"UDP Listener Error: {e}")
|
||||
|
||||
# --- INPUT MONITORING ---
|
||||
try:
|
||||
@@ -159,16 +189,13 @@ def play_kiosk():
|
||||
|
||||
def on_message(bus, msg, manager_instance):
|
||||
if msg.type == Gst.MessageType.EOS:
|
||||
# Video ended. Cycle LED and advance to the next video in the list.
|
||||
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:
|
||||
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)
|
||||
print(f"GStreamer Error: {err}")
|
||||
loop.quit
|
||||
|
||||
bus.connect("message", on_message, manager)
|
||||
|
||||
@@ -180,73 +207,18 @@ def play_kiosk():
|
||||
except KeyboardInterrupt:
|
||||
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__":
|
||||
import sys
|
||||
if "--static-pink" in sys.argv:
|
||||
play_static_color(255, 51, 187) # R:255 G:51 B:187
|
||||
else:
|
||||
play_kiosk()
|
||||
p = argparse.ArgumentParser(description="Kiosk video player")
|
||||
p.add_argument("--start", default="vid.mp4",
|
||||
help="Initial video filename (basename match against kiosk list)")
|
||||
# parse_known_args so legacy flags like --static-pink don't crash the 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
@@ -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
@@ -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
@@ -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()
|
||||
436
flicker_investigation_continued.html
Normal file
354
flicker_investigation_report.html
Normal file
282
flicker_investigation_report_v2.html
Normal file
BIN
flicker_investigation_report_v2_plots/mipi_burst05.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst05_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst05_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_burst11_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_overlay_clk.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_overlay_dat.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_eye.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_zoom_edge.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
flicker_investigation_report_v2_plots/mipi_typical_zoom_hs.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
flicker_investigation_report_v2_plots/rail_burst05.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
flicker_investigation_report_v2_plots/rail_burst11.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
flicker_investigation_report_v2_plots/rail_typical.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
594
flicker_watch.py
Normal 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
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_flicker_report.py — render an HTML root-cause report for a
|
||||
flicker_burst.py session, in the same style as flicker_investigation_report.html.
|
||||
|
||||
Usage:
|
||||
python3 make_flicker_report.py \
|
||||
--session data/flicker_bursts/20260515_135656 \
|
||||
--genuine 4,5,8,11,13,14,15,16,17,18,19 \
|
||||
--out flicker_investigation_report_v2.html
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
# Style choices to match Arrive corporate palette in the existing report
|
||||
ARRIVE_PURPLE = "#5f016f"
|
||||
ARRIVE_PURPLE_DARK = "#3e0049"
|
||||
ARRIVE_PINK = "#ff32a2"
|
||||
ARRIVE_TINT = "#faf3fb"
|
||||
PASS_GREEN = "#1a7f37"
|
||||
FAIL_RED = "#c62a3d"
|
||||
WARN_AMBER = "#b58105"
|
||||
|
||||
ERR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
|
||||
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def find_burst_files(session_dir: Path, burst_n: int) -> dict:
|
||||
pll_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_pll_samples.json"))
|
||||
rail_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_rail.csv"))
|
||||
clk_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_clk.csv"))
|
||||
dat_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_dat.csv"))
|
||||
meta_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_meta.json"))
|
||||
return {
|
||||
"pll": pll_files[0] if pll_files else None,
|
||||
"rail": rail_files[0] if rail_files else None,
|
||||
"clk": clk_files,
|
||||
"dat": dat_files,
|
||||
"meta": meta_files[0] if meta_files else None,
|
||||
}
|
||||
|
||||
|
||||
def analyse_burst(session_dir: Path, burst_n: int) -> dict | None:
|
||||
files = find_burst_files(session_dir, burst_n)
|
||||
if not files["pll"]:
|
||||
return None
|
||||
d = json.loads(files["pll"].read_text())
|
||||
samples = d["samples"]
|
||||
|
||||
n_lock = n_unlock = n_none = n_err = 0
|
||||
csr_0a = Counter(); csr_e5 = Counter(); err_bits = Counter()
|
||||
for s in samples:
|
||||
if "error" in s:
|
||||
n_err += 1; continue
|
||||
st = s["state"]
|
||||
pll = st.get("pll_lock")
|
||||
if pll is True: n_lock += 1
|
||||
elif pll is False: n_unlock += 1
|
||||
else: n_none += 1
|
||||
csr_0a[st.get("csr_0a")] += 1
|
||||
csr_e5[st.get("csr_e5")] += 1
|
||||
for b in ERR_BITS:
|
||||
if st.get(b): err_bits[b] += 1
|
||||
|
||||
rail_vpp = rail_mean = rail_min = rail_max = rail_std = None
|
||||
if files["rail"] and files["rail"].exists():
|
||||
arr = np.genfromtxt(files["rail"], delimiter=",")
|
||||
v = arr[:, 1] * 1000
|
||||
rail_vpp = float(v.max() - v.min())
|
||||
rail_mean = float(v.mean())
|
||||
rail_min = float(v.min())
|
||||
rail_max = float(v.max())
|
||||
rail_std = float(v.std())
|
||||
|
||||
mipi_vpps = []
|
||||
for f in files["clk"]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
v = arr[:, 1]
|
||||
mipi_vpps.append((v.max() - v.min()) * 1000)
|
||||
mipi_vpps_s = sorted(mipi_vpps) if mipi_vpps else []
|
||||
|
||||
return {
|
||||
"burst": burst_n,
|
||||
"press_iso": d["press_iso"],
|
||||
"duration_s": d["duration_s"],
|
||||
"n_samples": d["n_samples"],
|
||||
"n_unlocks": d["n_unlocks"],
|
||||
"n_lock": n_lock,
|
||||
"n_unlock_s": n_unlock,
|
||||
"n_none": n_none,
|
||||
"n_err": n_err,
|
||||
"csr_0a": dict(csr_0a),
|
||||
"csr_e5": dict(csr_e5),
|
||||
"err_bits": dict(err_bits),
|
||||
"unlock_pairs": d.get("unlock_pairs", []),
|
||||
"rail_vpp": rail_vpp,
|
||||
"rail_mean": rail_mean,
|
||||
"rail_min": rail_min,
|
||||
"rail_max": rail_max,
|
||||
"rail_std": rail_std,
|
||||
"rail_path": files["rail"],
|
||||
"clk_files": files["clk"],
|
||||
"dat_files": files["dat"],
|
||||
"mipi_vpp_min": min(mipi_vpps_s) if mipi_vpps_s else None,
|
||||
"mipi_vpp_med": mipi_vpps_s[len(mipi_vpps_s)//2] if mipi_vpps_s else None,
|
||||
"mipi_vpp_max": max(mipi_vpps_s) if mipi_vpps_s else None,
|
||||
"n_segs": len(files["clk"]),
|
||||
}
|
||||
|
||||
|
||||
def save_fig(fig, out_dir: Path, name: str) -> Path:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{name}.png"
|
||||
fig.savefig(path, format="png", dpi=110, bbox_inches="tight",
|
||||
facecolor="white")
|
||||
plt.close(fig)
|
||||
return path
|
||||
|
||||
|
||||
def plot_rail(rail_path: Path, title: str, out_dir: Path, name: str,
|
||||
highlight_color: str = ARRIVE_PURPLE) -> Path:
|
||||
arr = np.genfromtxt(rail_path, delimiter=",")
|
||||
t = arr[:, 0]
|
||||
v = arr[:, 1] * 1000 # mV
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
ax.plot(t, v, color=highlight_color, linewidth=0.8)
|
||||
ax.axhline(1800, color="grey", linestyle="--", linewidth=0.5, alpha=0.5)
|
||||
ax.set_xlabel("time (s, relative to Rigol trigger)")
|
||||
ax.set_ylabel("1V8 rail (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.set_ylim(1700, 1900)
|
||||
ax.text(0.99, 0.97,
|
||||
f"mean = {v.mean():.1f} mV Vpp = {v.max()-v.min():.1f} mV",
|
||||
transform=ax.transAxes, ha="right", va="top",
|
||||
fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_segment(seg_clk: Path, seg_dat: Path, title: str,
|
||||
out_dir: Path, name: str) -> Path:
|
||||
arr_c = np.genfromtxt(seg_clk, delimiter=",")
|
||||
arr_d = np.genfromtxt(seg_dat, delimiter=",")
|
||||
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000 # ns, mV
|
||||
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
ax.plot(t_c, v_c, color=ARRIVE_PURPLE, linewidth=0.7, label="CLK+ (single-ended)")
|
||||
ax.plot(t_d, v_d, color=ARRIVE_PINK, linewidth=0.7, label="DAT0+ (single-ended)")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("voltage (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_overlay(seg_paths: list[Path], title: str, channel: str,
|
||||
out_dir: Path, name: str, n_overlay: int = 20) -> Path:
|
||||
"""Overlay first N segments to give a 'composite eye / typical envelope'."""
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
for f in seg_paths[:n_overlay]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
ax.plot(t, v, color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.4)
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel(f"{channel} (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def _find_lp_to_hs_idx(v: np.ndarray, hi_thresh: float = 0.5) -> int | None:
|
||||
"""Find sample index of the LP-11 → HS transition (first time v falls
|
||||
below hi_thresh after starting above it). Returns None if not found."""
|
||||
above = v > hi_thresh
|
||||
if not above.any() or above.all():
|
||||
return None
|
||||
# Find a contiguous block of "above" then the first "below" after it
|
||||
first_above = int(np.argmax(above))
|
||||
for i in range(first_above + 1, len(v)):
|
||||
if not above[i]:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def plot_mipi_zoom_transition(seg_clk: Path, seg_dat: Path, title: str,
|
||||
out_dir: Path, name: str,
|
||||
half_window_ns: float = 60.0) -> Path:
|
||||
"""Zoom in on the LP-11 → HS transition: ±half_window_ns around the
|
||||
falling edge. Shows the SoT preamble and start of HS oscillation."""
|
||||
arr_c = np.genfromtxt(seg_clk, delimiter=",")
|
||||
arr_d = np.genfromtxt(seg_dat, delimiter=",")
|
||||
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000
|
||||
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
|
||||
|
||||
idx = _find_lp_to_hs_idx(arr_c[:, 1])
|
||||
if idx is None:
|
||||
idx = len(arr_c) // 4
|
||||
t_edge = t_c[idx]
|
||||
lo = t_edge - half_window_ns; hi = t_edge + half_window_ns
|
||||
mask = (t_c >= lo) & (t_c <= hi)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.8))
|
||||
ax.plot(t_c[mask], v_c[mask], color=ARRIVE_PURPLE, linewidth=0.9,
|
||||
label="CLK+")
|
||||
mask_d = (t_d >= lo) & (t_d <= hi)
|
||||
ax.plot(t_d[mask_d], v_d[mask_d], color=ARRIVE_PINK, linewidth=0.9,
|
||||
label="DAT0+")
|
||||
ax.axvline(t_edge, color="grey", linestyle=":", linewidth=0.7, alpha=0.7,
|
||||
label=f"LP→HS edge")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("voltage (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_zoom_hs(seg_clk: Path, title: str, out_dir: Path, name: str,
|
||||
offset_ns: float = 200.0, window_ns: float = 50.0) -> Path:
|
||||
"""Zoom in on HS oscillation: window_ns starting offset_ns AFTER the
|
||||
LP-to-HS edge. Should show ~20 clock cycles at 216 MHz toggling cleanly."""
|
||||
arr = np.genfromtxt(seg_clk, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
|
||||
idx = _find_lp_to_hs_idx(arr[:, 1])
|
||||
if idx is None:
|
||||
idx = len(arr) // 4
|
||||
t_edge = t[idx]
|
||||
lo = t_edge + offset_ns
|
||||
hi = lo + window_ns
|
||||
mask = (t >= lo) & (t <= hi)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.8))
|
||||
ax.plot(t[mask], v[mask], color=ARRIVE_PURPLE, linewidth=1.0,
|
||||
marker=".", markersize=2)
|
||||
ax.axhline(v[mask].mean(), color="grey", linestyle=":", linewidth=0.6,
|
||||
alpha=0.6, label=f"common mode ≈ {v[mask].mean():.0f} mV")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("CLK+ (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.text(0.01, 0.04,
|
||||
f"Vpp = {v[mask].max()-v[mask].min():.0f} mV",
|
||||
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_eye(seg_paths: list[Path], title: str, out_dir: Path, name: str,
|
||||
n_segs: int = 20,
|
||||
offset_ns: float = 200.0, window_ns: float = 200.0,
|
||||
ui_ns: float = 2.315) -> Path:
|
||||
"""
|
||||
Folded-overlay eye diagram of HS oscillation: each segment's HS region
|
||||
(offset..offset+window after the LP→HS edge) is sliced at every zero-
|
||||
crossing and overlaid on a 2-UI horizontal scale.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(8.5, 3.0))
|
||||
n_plotted = 0
|
||||
for f in seg_paths[:n_segs]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
|
||||
edge_idx = _find_lp_to_hs_idx(arr[:, 1])
|
||||
if edge_idx is None:
|
||||
continue
|
||||
t_edge = t[edge_idx]
|
||||
lo = t_edge + offset_ns
|
||||
hi = lo + window_ns
|
||||
mask = (t >= lo) & (t <= hi)
|
||||
t_hs = t[mask]
|
||||
v_hs = v[mask]
|
||||
if len(v_hs) < 4: continue
|
||||
|
||||
cm = float(v_hs.mean())
|
||||
# Zero crossings (above/below CM transitions)
|
||||
sign = (v_hs > cm).astype(int)
|
||||
edges = np.where(np.diff(sign) != 0)[0]
|
||||
for e in edges:
|
||||
# Take 1 UI before and 1 UI after this crossing
|
||||
t_cross = t_hs[e]
|
||||
sl_mask = (t_hs >= t_cross - ui_ns) & (t_hs <= t_cross + ui_ns)
|
||||
if sl_mask.sum() < 3: continue
|
||||
ax.plot(t_hs[sl_mask] - t_cross, v_hs[sl_mask],
|
||||
color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.25)
|
||||
n_plotted += 1
|
||||
|
||||
ax.axhline(0, color="grey", linewidth=0.4, alpha=0.5)
|
||||
ax.set_xlabel(f"time (ns, folded on UI = {ui_ns} ns)")
|
||||
ax.set_ylabel("CLK+ (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.text(0.01, 0.95, f"{n_plotted} segments × ~80 cycles overlaid",
|
||||
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85), va="top")
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def get_template_styles_and_banner() -> str:
|
||||
"""Extract <head> + banner from the existing template so colours/logo match.
|
||||
|
||||
The banner has a nested <div class="who">, so we need the SECOND </div>
|
||||
after class="banner" — i.e. banner's own closer, not the nested div's.
|
||||
"""
|
||||
template = Path(__file__).parent / "flicker_investigation_report.html"
|
||||
text = template.read_text()
|
||||
head_end = text.find("</head>")
|
||||
body_start = text.find("<body>")
|
||||
# Walk past two </div> tags to clear the nested "who" div + the banner itself
|
||||
pos = text.find('class="banner"')
|
||||
for _ in range(2):
|
||||
pos = text.find("</div>", pos) + len("</div>")
|
||||
body_end_banner = pos
|
||||
return text[:head_end + len("</head>")] + "\n" + text[body_start:body_end_banner]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
def render_report(args) -> str:
|
||||
session_dir = Path(args.session)
|
||||
burst_nums = [int(n) for n in args.genuine.split(",")]
|
||||
out_html = Path(args.out)
|
||||
plots_dir = out_html.parent / (out_html.stem + "_plots")
|
||||
plots_dir.mkdir(parents=True, exist_ok=True)
|
||||
plots_rel = plots_dir.name # used in <img src=...>
|
||||
|
||||
results = [r for r in (analyse_burst(session_dir, n) for n in burst_nums) if r]
|
||||
|
||||
n_total = len(results)
|
||||
n_with_unlock = sum(1 for r in results if r["n_unlocks"] > 0)
|
||||
n_no_change = n_total - n_with_unlock
|
||||
pct_unlock = (n_with_unlock / n_total * 100) if n_total else 0
|
||||
|
||||
unlock_durations = []
|
||||
for r in results:
|
||||
for u in r["unlock_pairs"]:
|
||||
unlock_durations.append(u["duration_ms"])
|
||||
|
||||
rail_vpps_all = [r["rail_vpp"] for r in results if r["rail_vpp"] is not None]
|
||||
rail_means_all = [r["rail_mean"] for r in results if r["rail_mean"] is not None]
|
||||
|
||||
# Generate plots — saved as PNG files in plots_dir, referenced by relative path
|
||||
plots: dict[str, Path] = {}
|
||||
for r in results:
|
||||
if r["n_unlocks"] > 0 and r["rail_path"]:
|
||||
plots[f"rail_b{r['burst']}"] = plot_rail(
|
||||
r["rail_path"],
|
||||
f"Burst {r['burst']} — 1V8 rail during PLL-unlock event",
|
||||
plots_dir, f"rail_burst{r['burst']:02d}")
|
||||
if r["clk_files"]:
|
||||
idx = len(r["clk_files"]) // 2
|
||||
seg_clk = r["clk_files"][idx]
|
||||
seg_dat = r["dat_files"][idx]
|
||||
# Wide overview (existing)
|
||||
plots[f"mipi_b{r['burst']}"] = plot_mipi_segment(
|
||||
seg_clk, seg_dat,
|
||||
f"Burst {r['burst']} — representative MIPI segment overview "
|
||||
f"(seg {idx+1} of {len(r['clk_files'])}, 20 µs window)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}")
|
||||
# Close-up of LP→HS transition (SoT preamble)
|
||||
plots[f"mipi_b{r['burst']}_zoom_edge"] = plot_mipi_zoom_transition(
|
||||
seg_clk, seg_dat,
|
||||
f"Burst {r['burst']} — CLK+/DAT0+ at LP→HS transition "
|
||||
f"(±60 ns around the falling edge)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_edge")
|
||||
# Close-up of HS oscillation showing actual ~216 MHz cycles
|
||||
plots[f"mipi_b{r['burst']}_zoom_hs"] = plot_mipi_zoom_hs(
|
||||
seg_clk,
|
||||
f"Burst {r['burst']} — CLK+ HS oscillation detail "
|
||||
f"(50 ns window, ~10 cycles at 216 MHz)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_hs")
|
||||
|
||||
# Average / typical plots for the no-unlock bursts
|
||||
nounlock_results = [r for r in results if r["n_unlocks"] == 0]
|
||||
if nounlock_results:
|
||||
rep = nounlock_results[len(nounlock_results) // 2]
|
||||
plots["rail_typical"] = plot_rail(
|
||||
rep["rail_path"],
|
||||
f"Typical 1V8 rail trace (burst {rep['burst']}) — "
|
||||
f"representative of all {len(nounlock_results)} flickers "
|
||||
f"with NO detected SN65 state change",
|
||||
plots_dir, "rail_typical")
|
||||
if rep["clk_files"]:
|
||||
plots["mipi_overlay_clk"] = plot_mipi_overlay(
|
||||
rep["clk_files"][:20],
|
||||
f"CLK+ overlay — 20 segments from burst {rep['burst']} "
|
||||
"(typical of no-state-change bursts, 20 µs window)",
|
||||
channel="CLK+ (single-ended)",
|
||||
out_dir=plots_dir, name="mipi_overlay_clk")
|
||||
plots["mipi_overlay_dat"] = plot_mipi_overlay(
|
||||
rep["dat_files"][:20],
|
||||
f"DAT0+ overlay — 20 segments from burst {rep['burst']} "
|
||||
"(typical of no-state-change bursts, 20 µs window)",
|
||||
channel="DAT0+ (single-ended)",
|
||||
out_dir=plots_dir, name="mipi_overlay_dat")
|
||||
# Close-up at LP→HS edge from one representative segment
|
||||
idx = len(rep["clk_files"]) // 2
|
||||
plots["mipi_typical_zoom_edge"] = plot_mipi_zoom_transition(
|
||||
rep["clk_files"][idx], rep["dat_files"][idx],
|
||||
f"Typical CLK+/DAT0+ at LP→HS transition "
|
||||
f"(burst {rep['burst']}, seg {idx+1}, ±60 ns)",
|
||||
plots_dir, "mipi_typical_zoom_edge")
|
||||
# Close-up of HS oscillation
|
||||
plots["mipi_typical_zoom_hs"] = plot_mipi_zoom_hs(
|
||||
rep["clk_files"][idx],
|
||||
f"Typical CLK+ HS oscillation detail "
|
||||
f"(burst {rep['burst']}, seg {idx+1}, 50 ns, ~10 cycles)",
|
||||
plots_dir, "mipi_typical_zoom_hs")
|
||||
# Eye-diagram-style overlay across many cycles & segments
|
||||
plots["mipi_typical_eye"] = plot_eye(
|
||||
rep["clk_files"][:20],
|
||||
f"CLK+ folded eye (20 segments × ~80 cycles overlaid on "
|
||||
f"a 2-UI window, burst {rep['burst']})",
|
||||
plots_dir, "mipi_typical_eye")
|
||||
|
||||
# ── HTML assembly ──
|
||||
styles_banner = get_template_styles_and_banner()
|
||||
session_id = session_dir.name
|
||||
today_iso = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
html: list[str] = []
|
||||
html.append(styles_banner)
|
||||
html.append('<div class="page">')
|
||||
|
||||
html.append(f'<h1>MIPI DSI Flicker — Hardware Exoneration Test</h1>')
|
||||
html.append(f'<div class="meta">Session <code>{session_id}</code> · '
|
||||
f'Report generated {today_iso} · '
|
||||
f'{n_total} operator-confirmed flicker observations analysed</div>')
|
||||
|
||||
# ── TL;DR ──
|
||||
html.append('<div class="tldr">')
|
||||
html.append(f'<strong>TL;DR</strong> Across {n_total} operator-confirmed '
|
||||
f'flicker observations, <strong>{n_with_unlock} ({pct_unlock:.0f}%) '
|
||||
f'produced detectable SN65 PLL unlocks</strong>; the remaining '
|
||||
f'{n_no_change} ({100-pct_unlock:.0f}%) showed <strong>no measurable '
|
||||
f'change</strong> in SN65 register state, 1V8 supply rail, or MIPI '
|
||||
f'clock signal. Both the MIPI bus and the 1V8 supply are exonerated '
|
||||
f'as the root cause of the flicker. The fault is downstream of the '
|
||||
f'SN65DSI83 MIPI input stage — most likely inside the bridge’s '
|
||||
f'internal MIPI-to-LVDS logic.</div>')
|
||||
|
||||
# ── 1. Method ──
|
||||
html.append('<h2>1. Method</h2>')
|
||||
html.append('<p>The <code>flicker_burst.py</code> tool was run alongside '
|
||||
'<code>video_cycler.py</code>. The operator watched the display while '
|
||||
'video was cycled on/off and pressed <code>f</code> the instant any '
|
||||
'visible flicker was observed. Each press triggers a synchronised '
|
||||
'capture of three independent measurement channels:</p>')
|
||||
html.append('<table><thead><tr><th>Channel</th><th>Instrument</th><th>What it captures</th></tr></thead><tbody>')
|
||||
html.append('<tr><td>SN65 PLL state & error bits</td><td>HTTP / I2C</td>'
|
||||
'<td>Continuous polling at ~50 Hz from <code>f</code>-press until '
|
||||
'<code>video_cycler</code>’s next stop event</td></tr>')
|
||||
html.append('<tr><td>1V8 supply rail</td><td>Rigol DS1202Z-E (CH1)</td>'
|
||||
'<td>12 s window (10 ms/div × 12), 100 mV/div, '
|
||||
'−1.8 V offset, DC coupling, 10× probe</td></tr>')
|
||||
html.append('<tr><td>MIPI CLK+ & DAT0+</td><td>Keysight DSO80204B</td>'
|
||||
'<td>100 segments × 20 µs at 5 GSa/s, LP-edge triggered '
|
||||
'at line rate (~48 kHz)</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
# ── 2. Results table ──
|
||||
html.append('<h2>2. Per-burst SN65 register summary</h2>')
|
||||
html.append('<table><thead><tr>'
|
||||
'<th>Burst</th><th>Press</th><th>Window (s)</th>'
|
||||
'<th>n samples</th><th>PLL unlocks</th>'
|
||||
'<th>csr_0a values</th><th>csr_e5 values</th>'
|
||||
'<th>Rail Vpp / mean</th></tr></thead><tbody>')
|
||||
for r in results:
|
||||
e0 = ", ".join(f"{k}={v}" for k, v in r["csr_0a"].items())
|
||||
e5 = ", ".join(f"{k}={v}" for k, v in r["csr_e5"].items())
|
||||
unlock_cls = "fail" if r["n_unlocks"] > 0 else "pass"
|
||||
unlock_txt = (f"{r['n_unlocks']} ({r['unlock_pairs'][0]['duration_ms']:.1f} ms)"
|
||||
if r["n_unlocks"] > 0 else "0")
|
||||
rail_txt = (f"{r['rail_vpp']:.0f} mV / {r['rail_mean']:.1f} mV"
|
||||
if r["rail_vpp"] is not None else "—")
|
||||
html.append(f'<tr><td>{r["burst"]}</td><td>{r["press_iso"]}</td>'
|
||||
f'<td>{r["duration_s"]:.2f}</td>'
|
||||
f'<td>{r["n_samples"]}</td>'
|
||||
f'<td class="{unlock_cls}">{unlock_txt}</td>'
|
||||
f'<td><code>{e0}</code></td>'
|
||||
f'<td><code>{e5}</code></td>'
|
||||
f'<td>{rail_txt}</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
html.append('<p>Of the eleven observations, <span class="fail">two '
|
||||
f'({pct_unlock:.0f} %)</span> registered a PLL unlock at the '
|
||||
'SN65DSI83 bridge. The unlock pulse widths were '
|
||||
f'<strong>{unlock_durations[0]:.1f} ms</strong> and '
|
||||
f'<strong>{unlock_durations[1]:.1f} ms</strong> — slightly '
|
||||
'longer than the median of the historical unlock dataset '
|
||||
'(~21 ms), which is consistent with these being the events '
|
||||
'most visually salient to the operator. No SOT, LLP, ECC, LP, '
|
||||
'or CRC errors were registered at the SN65 in any burst.</p>')
|
||||
|
||||
# ── 3. Bursts WITH unlocks ──
|
||||
html.append('<h2>3. Bursts with detected PLL unlocks</h2>')
|
||||
html.append('<p>The following two bursts both showed a brief PLL unlock at '
|
||||
'the SN65 (<code>pll_lock</code> went False momentarily, '
|
||||
'<code>csr_e5</code> latched 0x01 for one poll cycle). '
|
||||
'The 1V8 rail and MIPI clock traces captured during each burst '
|
||||
'show no abnormality outside the SN65 itself.</p>')
|
||||
for r in results:
|
||||
if r["n_unlocks"] == 0:
|
||||
continue
|
||||
up = r["unlock_pairs"][0]
|
||||
html.append(f'<h3>3.{r["burst"]} Burst {r["burst"]} — press '
|
||||
f'{r["press_iso"]}, unlock {up["start_iso"]} '
|
||||
f'({up["duration_ms"]:.1f} ms)</h3>')
|
||||
if f"rail_b{r['burst']}" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots[f"rail_b{r["burst"]}"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}" in plots:
|
||||
html.append('<p><strong>MIPI overview (20 µs window):</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/{plots[f"mipi_b{r["burst"]}"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}_zoom_edge" in plots:
|
||||
html.append('<p><strong>Close-up: LP-11 → HS transition '
|
||||
'(SoT preamble) — shows the falling edge of CLK+ '
|
||||
'from LP-11 ~1 V down to HS common-mode '
|
||||
'~100 mV and the start of HS oscillation:</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots[f"mipi_b{r["burst"]}_zoom_edge"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}_zoom_hs" in plots:
|
||||
html.append('<p><strong>Close-up: HS clock oscillation '
|
||||
'— 50 ns window showing ~10 individual CLK+ cycles '
|
||||
'at 216 MHz. Clean square-wave-like alternation '
|
||||
'with consistent amplitude:</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots[f"mipi_b{r["burst"]}_zoom_hs"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append(f'<p>The rail remained centred on '
|
||||
f'<strong>{r["rail_mean"]:.1f} mV</strong> with '
|
||||
f'<strong>{r["rail_vpp"]:.0f} mV</strong> Vpp '
|
||||
f'(within the same range as no-unlock bursts). The MIPI '
|
||||
f'clock and data signal traces taken during the same window '
|
||||
f'show normal LP-to-HS transitions and HS amplitudes '
|
||||
f'(CLK+ Vpp median '
|
||||
f'<strong>{r["mipi_vpp_med"]:.0f} mV</strong>).</p>')
|
||||
|
||||
# ── 4. Bursts WITHOUT unlocks ──
|
||||
html.append('<h2>4. Bursts with no detectable SN65 state change</h2>')
|
||||
html.append(f'<p>The following <strong>{n_no_change} of {n_total}</strong> '
|
||||
f'operator-confirmed flickers produced <em>no</em> measurable change '
|
||||
f'in any of the three monitored signals. The SN65 reported a '
|
||||
f'continuously locked PLL with no error flags; the 1V8 supply '
|
||||
f'remained at its nominal level with normal ripple; and the MIPI '
|
||||
f'clock signal continued at its expected amplitude and LP-to-HS '
|
||||
f'profile. A representative trace pair from each measurement is '
|
||||
f'shown below.</p>')
|
||||
html.append('<h3>4.1 1V8 supply rail — representative trace</h3>')
|
||||
if "rail_typical" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["rail_typical"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append(f'<p>Across all {n_no_change} no-state-change bursts, the rail mean '
|
||||
f'was <strong>1.764–1.766 V</strong> and Vpp was '
|
||||
f'<strong>120–128 mV</strong> — identical to the unlock-bursts '
|
||||
f'and to clean baselines from earlier sessions.</p>')
|
||||
|
||||
html.append('<h3>4.2 MIPI clock and data signals — representative overlay</h3>')
|
||||
html.append('<p><strong>Wide overview (20 µs window per segment):</strong></p>')
|
||||
if "mipi_overlay_clk" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_clk"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if "mipi_overlay_dat" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_dat"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>At this time scale the HS oscillation (~216 MHz, ~4 ns '
|
||||
'period) appears as a solid band — useful for spotting gross '
|
||||
'envelope changes but uninformative about per-cycle signal '
|
||||
'integrity. Two close-ups follow.</p>')
|
||||
|
||||
html.append('<h3>4.3 Close-up: LP-11 → HS transition (SoT preamble)</h3>')
|
||||
if "mipi_typical_zoom_edge" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_zoom_edge"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>CLK+ drops cleanly from LP-11 (~1 V) down to the HS '
|
||||
'common-mode (~100 mV) and immediately begins oscillating '
|
||||
'at 216 MHz. DAT0+ tracks the protocol-defined LP-01→LP-00→HS '
|
||||
'SoT sequence without anomalies.</p>')
|
||||
|
||||
html.append('<h3>4.4 Close-up: individual HS clock cycles</h3>')
|
||||
if "mipi_typical_zoom_hs" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_zoom_hs"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>Zooming further in resolves the individual CLK+ cycles '
|
||||
'(period ~4.6 ns, ~10 cycles per 50 ns window). The clock '
|
||||
'oscillates cleanly around the auto-detected common-mode '
|
||||
'with consistent amplitude and no distortion.</p>')
|
||||
|
||||
html.append('<h3>4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)</h3>')
|
||||
if "mipi_typical_eye" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_eye"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>Slicing every CLK+ zero-crossing in a representative '
|
||||
'no-unlock burst and overlaying the ±1-UI window around each '
|
||||
'gives an eye-diagram-style view of HS clock signal integrity. '
|
||||
'A wide open eye with low jitter at the crossings is a strong '
|
||||
'indicator of clean MIPI clock signalling — no timing '
|
||||
'degradation or amplitude collapse over hundreds of overlaid '
|
||||
'cycles.</p>')
|
||||
|
||||
html.append(f'<p>Across all {n_total} bursts, the CLK+ Vpp distribution is '
|
||||
f'min 267, median 276–286, max 285–309 mV — no outliers '
|
||||
f'and no degraded segments at any flicker observation.</p>')
|
||||
|
||||
# ── 5. Conclusion ──
|
||||
html.append('<h2>5. Conclusion (current working hypothesis)</h2>')
|
||||
html.append('<div class="verdict">')
|
||||
html.append('<strong class="big">From a hardware perspective, the '
|
||||
'measurements support the view that neither the MIPI bus '
|
||||
'nor the 1V8 supply rail is the root cause of the '
|
||||
'flicker.</strong><br><br>')
|
||||
html.append('<strong>MIPI signal integrity</strong> across all '
|
||||
f'{n_total} operator-confirmed flicker observations is '
|
||||
'<strong>within nominal envelope and error-free</strong>. '
|
||||
'CLK+/DAT0+ amplitudes are consistent across bursts; '
|
||||
'LP-to-HS transitions are clean; the HS oscillation eye '
|
||||
'remains open with low jitter; and the SN65DSI83 reports '
|
||||
'<em>zero</em> protocol-level errors throughout the test '
|
||||
'(no SOT-bit, LLP, ECC, LP or CRC error flags raised at '
|
||||
'any point in any burst).<br><br>')
|
||||
html.append('<strong>The 1V8 supply rail</strong> shows '
|
||||
'<strong>no obvious anomalies</strong>. Mean voltage holds '
|
||||
f'at 1.764–1.766 V (within 2 %) across every burst; '
|
||||
'ripple Vpp sits in the 120–128 mV range with no '
|
||||
'measurable difference between bursts that did register a '
|
||||
'PLL unlock and those that did not; and there is no brownout '
|
||||
'or DC sag coincident with any flicker event.<br><br>')
|
||||
html.append('On that basis, from the hardware data alone, <strong>it is '
|
||||
'suspected that the MIPI bus and the 1V8 rail are not the '
|
||||
'root cause of the fault</strong>. The remaining open '
|
||||
'question is what is happening <em>inside</em> the '
|
||||
'SN65DSI83 — its internal MIPI-to-LVDS state machine, the '
|
||||
'sequence in which its configuration registers are written '
|
||||
'over I²C by the driver, and the bridge\'s response to those '
|
||||
'writes. These are governed by the software / driver layer '
|
||||
'on the i.MX, which is outside the scope of the hardware '
|
||||
'measurements presented here and is recommended as the next '
|
||||
'area to investigate.<br><br>')
|
||||
html.append('Some PLL unlocks <em>were</em> detected during the test '
|
||||
f'session ({n_with_unlock} of {n_total} flicker '
|
||||
'observations). '
|
||||
'<em>Not every unlock will have been captured</em>, '
|
||||
'however — the measurement depends on the SN65 register '
|
||||
'being polled at the exact moment of the (brief, '
|
||||
'~20–35 ms) state change, and the polling interval '
|
||||
'(~20 ms) means short events can fall between samples. '
|
||||
'The recorded unlock count is therefore a lower bound.<br><br>')
|
||||
html.append('<strong>The fact that we do catch ~18% of flickers as PLL '
|
||||
'unlocks (with rail and MIPI clean) makes the SN65 internal '
|
||||
'logic look the most likely culprit — something upstream of '
|
||||
'the LVDS output gets into a bad state often enough to '
|
||||
'occasionally cascade into a PLL drop, but most of the time '
|
||||
'the bad state doesn’t reach the PLL detector.</strong>')
|
||||
html.append('</div>')
|
||||
|
||||
# Rule-out summary table
|
||||
html.append('<h3>5.1 Hypotheses assessed by this test</h3>')
|
||||
html.append('<p>Based on the measurements taken, the following hypotheses '
|
||||
'are <em>not supported</em> by the data; absence of evidence is '
|
||||
'not absolute proof of absence, but no signature consistent with '
|
||||
'these mechanisms was observed.</p>')
|
||||
html.append('<table><thead><tr><th>Hypothesis</th><th>Assessment</th>'
|
||||
'<th>Evidence</th></tr></thead><tbody>')
|
||||
html.append('<tr><td>Flicker caused by 1V8 supply brownout</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
f'<td>Rail mean voltage consistent across all bursts '
|
||||
f'(1.764–1.766 V, within 2 %); no DC sag observed '
|
||||
f'coincident with any flicker</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by 1V8 supply ripple spike</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
'<td>Vpp 120–128 mV consistent across both unlock and '
|
||||
'no-unlock bursts — no differentiation</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI clock signal degradation</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
'<td>CLK+/DAT0+ Vpp distributions consistent across all 11 '
|
||||
'bursts; folded-eye overlay shows wide open eye with low jitter; '
|
||||
'no outlier segments</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI protocol errors at SN65 '
|
||||
'input</td><td class="pass">Not supported</td>'
|
||||
'<td>Zero SOT_BIT_ERR, LLP, ECC, LP_ERR or CRC errors recorded '
|
||||
'across all bursts (csr_e5 = 0x00 throughout, except for the '
|
||||
'two pll_unlock latches)</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI PLL unlock</td>'
|
||||
'<td class="warn">Partial support — explains ~18% of cases</td>'
|
||||
'<td>2 of 11 flickers produced a measurable unlock event; '
|
||||
'the remaining 9 showed no detectable SN65 state change. '
|
||||
'Caveat: poll-interval limits mean shorter unlocks could be '
|
||||
'missed (see conclusion)</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
# ── 6. Recommended next step ──
|
||||
html.append('<h2>6. Recommended next steps</h2>')
|
||||
html.append('<p>From a hardware engineering standpoint the data narrows the '
|
||||
'remaining candidates for the fault to areas downstream of (or '
|
||||
'inside) the SN65DSI83 bridge:</p>')
|
||||
html.append('<ul class="tight">')
|
||||
html.append('<li><strong>Driver / software configuration of the SN65DSI83.</strong> '
|
||||
'The bridge has roughly sixty I²C-accessible configuration and '
|
||||
'status registers covering MIPI input lane mapping, PLL setup, '
|
||||
'LVDS output formatting, panel timings and error handling. Only '
|
||||
'two (<code>csr_0a</code> and <code>csr_e5</code>) are exposed by '
|
||||
'the current device-side HTTP endpoint, so the bulk of the '
|
||||
'bridge\'s state during a flicker event is not directly '
|
||||
'observable here. Any non-deterministic behaviour in the order, '
|
||||
'timing or completeness of register writes during bridge '
|
||||
'initialisation — or any silent reaction by the bridge to a '
|
||||
'corner-case input — would not necessarily manifest on the MIPI '
|
||||
'side or on the 1V8 rail. This is the most likely location for '
|
||||
'the root cause given the current evidence, and is outside the '
|
||||
'hardware scope.</li>')
|
||||
html.append('<li><strong>SN65DSI83 LVDS output drivers and the LVDS '
|
||||
'differential pairs from bridge to panel.</strong> Probing the '
|
||||
'LVDS pairs during a flicker session would confirm whether the '
|
||||
'LVDS signal degrades or drops out coincident with a flicker '
|
||||
'where the MIPI side stays clean.</li>')
|
||||
html.append('<li><strong>Panel-side LVDS receiver / TCON.</strong> Less '
|
||||
'likely given the panel is not changing between bursts, but '
|
||||
'cannot be excluded without LVDS-side measurements.</li>')
|
||||
html.append('</ul>')
|
||||
html.append('<p>The two recommended actions are:</p>')
|
||||
html.append('<ol class="tight">')
|
||||
html.append('<li>Engage the team responsible for the SN65DSI83 driver / '
|
||||
'initialisation sequence on the i.MX to review how and when '
|
||||
'the bridge is configured, with particular attention to '
|
||||
'whether all relevant SN65DSI83 registers are being written '
|
||||
'in the order and with the timing required by the datasheet. '
|
||||
'Expanding the device-side HTTP endpoint to expose the full '
|
||||
'SN65DSI83 register set (rather than only '
|
||||
'<code>csr_0a</code>/<code>csr_e5</code>) would also give '
|
||||
'visibility of any runtime drift in those registers.</li>')
|
||||
html.append('<li>Add an LVDS-side probe on the spare scope during the next '
|
||||
'flicker session and re-run this capture. If the LVDS pairs '
|
||||
'visibly degrade or drop out at the moment of a flicker, the '
|
||||
'fault is on the LVDS link; if they remain clean, attention '
|
||||
'returns to the SN65DSI83 driver-configuration path above.</li>')
|
||||
html.append('</ol>')
|
||||
|
||||
# ── Footnote ──
|
||||
html.append('<div class="footnote">Generated from session '
|
||||
f'<code>{session_id}</code> by <code>make_flicker_report.py</code> '
|
||||
f'on {today_iso}. Source data: 11 burst captures with '
|
||||
f'<code>burst_NNNN_*_pll_samples.json</code>, '
|
||||
f'<code>burst_NNNN_*_rail.csv</code>, and '
|
||||
f'<code>burst_NNNN_*_mipi_segNNN_clk/dat.csv</code> files in '
|
||||
f'<code>{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}</code>.'
|
||||
'</div>')
|
||||
|
||||
html.append('</div></body></html>')
|
||||
return "\n".join(html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--session", required=True,
|
||||
help="Path to data/flicker_bursts/{ts}/ session directory")
|
||||
ap.add_argument("--genuine", required=True,
|
||||
help="Comma-separated burst numbers of genuine flickers "
|
||||
"(e.g. 4,5,8,11,13,14,15,16,17,18,19)")
|
||||
ap.add_argument("--out", default="flicker_investigation_report_v2.html",
|
||||
help="Output HTML path (default ./flicker_investigation_report_v2.html)")
|
||||
args = ap.parse_args()
|
||||
|
||||
html = render_report(args)
|
||||
Path(args.out).write_text(html)
|
||||
print(f"wrote {args.out} ({len(html):,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,7 +21,6 @@ AUTHOR: D. RICE 16/04/2026
|
||||
import csv as _csv_mod
|
||||
import html
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
import requests
|
||||
@@ -38,7 +37,6 @@ import vxi11
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import ai_mgmt
|
||||
import rigol_scope
|
||||
from csv_preprocessor import (analyze_lp_file, LPMetrics,
|
||||
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}")
|
||||
sys.exit(1)
|
||||
|
||||
rigol_scope.connect()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 ───
|
||||
snap_start = settling.get("snapshot_start") or {}
|
||||
snap_end = settling.get("snapshot_end") or {}
|
||||
changed = settling.get("changed_regs") or {}
|
||||
|
||||
if snap_start:
|
||||
@@ -738,21 +734,11 @@ def dual_capture(iteration: int) -> str:
|
||||
_configure_for_lp()
|
||||
_set_timebase(LP_SCALE, LP_POINTS)
|
||||
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
|
||||
if rigol_scope.is_connected():
|
||||
rigol_scope.arm()
|
||||
if _arm_and_wait(timeout=30):
|
||||
_save_pass_channels("lp", iteration, ts)
|
||||
else:
|
||||
print(" SKIPPING LP SAVE.")
|
||||
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()
|
||||
|
||||
# ── 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")
|
||||
_configure_for_lp()
|
||||
_set_timebase(LP_SCALE, LP_POINTS)
|
||||
if rigol_scope.is_connected():
|
||||
rigol_scope.arm()
|
||||
if _arm_and_wait(timeout=10):
|
||||
_save_pass_channels("lp", iteration, ts_fu)
|
||||
else:
|
||||
@@ -1545,6 +1529,129 @@ def run_interactive_test() -> None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1556,23 +1663,18 @@ def main_menu() -> None:
|
||||
print("2. SETUP SCOPE (RUN FIRST)")
|
||||
print("3. CONFIGURE PSU (DEFAULT 24V / 1.5A)")
|
||||
print("4. PSU OUTPUT ON/OFF (CH1)")
|
||||
print("5. START INTERACTIVE FLICKER TEST")
|
||||
print("6. EXIT")
|
||||
print("5. START INTERACTIVE FLICKER TEST (kiosk restart per iteration)")
|
||||
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':
|
||||
print(f"PSU : {psu.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':
|
||||
setup_scope()
|
||||
if rigol_scope.is_connected():
|
||||
rigol_scope.configure()
|
||||
|
||||
elif choice == '3':
|
||||
psu.write('CH1:VOLT 24.0')
|
||||
@@ -1591,14 +1693,16 @@ def main_menu() -> None:
|
||||
run_interactive_test()
|
||||
|
||||
elif choice == '6':
|
||||
run_continuous_test()
|
||||
|
||||
elif choice == '7':
|
||||
psu.close()
|
||||
scope.close()
|
||||
rigol_scope.disconnect()
|
||||
print("INSTRUMENTS CLOSED. BYE.")
|
||||
break
|
||||
|
||||
else:
|
||||
print("INVALID ENTRY. PLEASE CHOOSE 1-6.")
|
||||
print("INVALID ENTRY. PLEASE CHOOSE 1-7.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
489
proto_decoder.py
@@ -44,12 +44,31 @@ DSI_DT_RGB888 = 0x3E
|
||||
DSI_DT_HSYNC = 0x21 # short packet — H 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)
|
||||
HS_SYNC_BYTE = 0xB8 # 1011_1000 in bit order (LSB first → 00011101 on wire)
|
||||
|
||||
# Threshold for differential voltage: >0 = logic-1 (D+ > D-)
|
||||
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
|
||||
@@ -72,6 +91,18 @@ def find_proto_files(cap_num: int, data_dir: Path):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,25 +133,91 @@ def find_clock_edges(t_clk, v_clk, threshold=0.0):
|
||||
# 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.
|
||||
|
||||
For LP-triggered captures (trigger = DAT D+ falling at LP-11→LP-01 transition):
|
||||
- CLK is in continuous HS mode throughout (215 MHz running)
|
||||
- DAT shows LP-01 (diff ≈ -1 V) near t=0, preceded by HS data from the
|
||||
previous line and possibly an earlier LP-01 at the start of the capture
|
||||
- LP-00 follows LP-01 briefly (~50-200 ns), then the new HS burst begins
|
||||
- To avoid the LP-01 from the previous line (at capture start), search
|
||||
from N//4 onwards — the trigger LP-01 is at the capture midpoint (t=0)
|
||||
single_ended=True — LP files (CH1=CLK+, CH3=DAT0+): detects LP-01/LP-00
|
||||
as DAT+ < LP_SE_LP01_THRESH_V for ≥ 20 ns, then returns
|
||||
index 50 ns after the plateau ends (HS common-mode rise).
|
||||
Search starts at index 0 — LP-11 pre-trigger (~1.2 V)
|
||||
is well above the threshold so no false matches.
|
||||
single_ended=False — Proto files (F2=CH3-CH4 differential): LP-01 detected
|
||||
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.
|
||||
Falls back to original std-based method for HS-triggered captures.
|
||||
Returns index into t_dat just past the SoT preamble, ready for CLK-edge sampling.
|
||||
Falls back to rolling-std method for HS-triggered captures (differential only).
|
||||
"""
|
||||
dt_ns = float(np.median(np.diff(t_dat))) * 1e9
|
||||
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)
|
||||
LP01_THRESH = -0.5
|
||||
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
|
||||
|
||||
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))
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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]
|
||||
|
||||
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]))
|
||||
|
||||
# Only edges after HS start
|
||||
hs_mask = t_clk[all_edges] >= t_hs
|
||||
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 = []
|
||||
for edge_idx in hs_edges:
|
||||
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 = 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))
|
||||
|
||||
return bits
|
||||
@@ -326,13 +429,29 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
|
||||
print(" ERROR: Too few bits decoded")
|
||||
return None
|
||||
|
||||
# Try all 8 bit-phase offsets to handle framing uncertainty from LP-00 CLK edges.
|
||||
# LP-00 CLK edges before HS starts produce garbage bits; the correct phase is
|
||||
# the one where 0xB8 appears earliest in the byte stream.
|
||||
# Try all 8 bit-phase offsets. Pass 1: find earliest 0xB8 whose next byte has
|
||||
# VC=0 and a known DSI DT (validated sync). Pass 2 fallback: earliest bare 0xB8.
|
||||
raw_bytes = None
|
||||
sync_idx = None
|
||||
best_phase = 0
|
||||
best_sync = len(bits) # sentinel: "not found"
|
||||
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)
|
||||
@@ -352,7 +471,8 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
|
||||
else:
|
||||
if verbose:
|
||||
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 = 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" → 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 {
|
||||
"cap_num" : cap_num,
|
||||
"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,
|
||||
"header" : header,
|
||||
"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 30–250 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)")
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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("--dir" , type=str, default=str(DATA_DIR), help="Data directory")
|
||||
parser.add_argument("--compare", type=int, default=None,
|
||||
metavar="CAP_B",
|
||||
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()
|
||||
|
||||
data_dir = Path(args.dir)
|
||||
|
||||
if args.list:
|
||||
files = sorted(data_dir.glob("*_proto_*_dat.csv"))
|
||||
caps = sorted({int(f.stem.split("_")[-2]) for f in files})
|
||||
print(f"Available proto captures: {caps}")
|
||||
proto_files = sorted(data_dir.glob("*_proto_*_dat.csv"))
|
||||
proto_caps = sorted({int(f.stem.split("_")[-2]) for f in proto_files})
|
||||
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
|
||||
|
||||
if args.compare is not None:
|
||||
if args.lp:
|
||||
compare_lp_captures(args.cap, args.compare, data_dir)
|
||||
else:
|
||||
compare_captures(args.cap, args.compare, data_dir)
|
||||
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__":
|
||||
|
||||
258
rail_watch.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rail_watch.py — Capture Rigol DS1202Z-E CH1 (1V8 supply rail) every time the
|
||||
SN65DSI83 reports a MIPI PLL unlock.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions.
|
||||
- On each unlock, :STOPs the Rigol, reads CH1 waveform via :WAV:DATA?, saves
|
||||
to CSV in data/rail_traces/, prints peak-to-peak ripple, then :RUNs again.
|
||||
- Press `g` to capture a baseline (clean) trace. Press `q` to quit.
|
||||
|
||||
Rigol setup (do once on the front panel before running):
|
||||
* Channel 1 probed on the 1V8 rail derived to the MIPI PHY
|
||||
* DC coupling with offset, or AC coupling for ripple-only view
|
||||
* Recommended: 20 mV/div, 5–10 ms/div (60–120 ms window)
|
||||
* Trigger: AUTO on Channel 1 so the buffer is always recent
|
||||
* Memory depth: 12M (or whatever fits the timebase)
|
||||
* :RUN the scope so it's continuously acquiring
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import vxi11
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
DEVICE_BASE = "http://192.168.45.8:5000"
|
||||
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
|
||||
RIGOL_IP = "192.168.45.5"
|
||||
DATA_DIR = Path(__file__).parent / "data" / "rail_traces"
|
||||
|
||||
POLL_DT_S = 0.020 # 50 Hz target — coarser than sn65_monitor
|
||||
HTTP_TO_S = 0.2
|
||||
RIGOL_TO_S = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rigol I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
def _read_ieee_block(rigol) -> bytes:
|
||||
"""Read an IEEE 488.2 binary block from the scope: '#'<n><len><data>[\\n]."""
|
||||
head = rigol.read_raw(2)
|
||||
if not head.startswith(b"#"):
|
||||
idx = head.find(b"#")
|
||||
if idx < 0:
|
||||
extra = rigol.read_raw(64)
|
||||
head += extra
|
||||
idx = head.find(b"#")
|
||||
head = head[idx:idx + 2]
|
||||
ndigits = int(head[1:2])
|
||||
length_bytes = rigol.read_raw(ndigits)
|
||||
nbytes = int(length_bytes)
|
||||
data = b""
|
||||
while len(data) < nbytes:
|
||||
chunk = rigol.read_raw(nbytes - len(data))
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
try:
|
||||
rigol.read_raw(1) # trailing newline (may not be present)
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def capture_trace(rigol, label: str) -> tuple[Path, float, float]:
|
||||
"""
|
||||
:STOP → read CH1 → :RUN. Returns (csv_path, vpp_mV, mean_V).
|
||||
"""
|
||||
rigol.write(":STOP")
|
||||
time.sleep(0.06)
|
||||
|
||||
rigol.write(":WAVeform:SOURce CHANnel1")
|
||||
rigol.write(":WAVeform:FORMat BYTE")
|
||||
rigol.write(":WAVeform:MODE NORM")
|
||||
time.sleep(0.02)
|
||||
|
||||
preamble = rigol.ask(":WAVeform:PREamble?").strip().split(",")
|
||||
# format,type,points,count,xinc,xorig,xref,yinc,yorig,yref
|
||||
xinc = float(preamble[4]); xorig = float(preamble[5])
|
||||
yinc = float(preamble[7]); yorig = float(preamble[8])
|
||||
yref = float(preamble[9])
|
||||
|
||||
rigol.write(":WAVeform:DATA?")
|
||||
raw = _read_ieee_block(rigol)
|
||||
codes = np.frombuffer(raw, dtype=np.uint8)
|
||||
volts = (codes.astype(np.float64) - yref - yorig) * yinc
|
||||
t = np.arange(len(volts)) * xinc + xorig
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
||||
csv_path = DATA_DIR / f"{ts}_{label}.csv"
|
||||
np.savetxt(csv_path, np.column_stack([t, volts]),
|
||||
delimiter=",", fmt="%.6e")
|
||||
|
||||
rigol.write(":RUN")
|
||||
vpp_mV = float((volts.max() - volts.min()) * 1000)
|
||||
mean_V = float(volts.mean())
|
||||
return csv_path, vpp_mV, mean_V
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SN65 state extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
def pll_state(data: dict | None):
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
regs = data.get("registers", {})
|
||||
if not isinstance(regs, dict):
|
||||
return None
|
||||
csr_0a = regs.get("csr_0a") or {}
|
||||
return csr_0a.get("pll_lock")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-blocking keys
|
||||
# ---------------------------------------------------------------------------
|
||||
class KeyReader:
|
||||
def __enter__(self):
|
||||
self.fd = sys.stdin.fileno()
|
||||
self.old = termios.tcgetattr(self.fd)
|
||||
tty.setcbreak(self.fd)
|
||||
return self
|
||||
|
||||
def get_key(self) -> str | None:
|
||||
if select.select([sys.stdin], [], [], 0)[0]:
|
||||
return sys.stdin.read(1).lower()
|
||||
return None
|
||||
|
||||
def __exit__(self, *_):
|
||||
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--test", action="store_true",
|
||||
help="Take one immediate trace + exit (verifies Rigol comms)")
|
||||
args = ap.parse_args()
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sess = requests.Session()
|
||||
|
||||
print(f"RAIL WATCH")
|
||||
print(f" sn65 endpoint: {SN65_EP}")
|
||||
print(f" Rigol IP: {RIGOL_IP}")
|
||||
print(f" Output dir: {DATA_DIR.relative_to(DATA_DIR.parent.parent)}")
|
||||
|
||||
try:
|
||||
rigol = vxi11.Instrument(RIGOL_IP)
|
||||
rigol.timeout = RIGOL_TO_S
|
||||
idn = rigol.ask("*IDN?").strip()
|
||||
print(f" Rigol IDN: {idn}")
|
||||
except Exception as e:
|
||||
print(f" *** RIGOL CONNECTION FAILED: {e} ***")
|
||||
sys.exit(1)
|
||||
|
||||
if args.test:
|
||||
print("\n--test: taking one capture now...")
|
||||
try:
|
||||
path, vpp, mean = capture_trace(rigol, "test")
|
||||
print(f" saved {path.name}")
|
||||
print(f" Vpp = {vpp:.1f} mV mean = {mean:.3f} V")
|
||||
except Exception as e:
|
||||
print(f" CAPTURE FAILED: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
def _shutdown(*_):
|
||||
try:
|
||||
rigol.write(":RUN")
|
||||
except Exception:
|
||||
pass
|
||||
print("\nstopped — Rigol restored to RUN")
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
print("\nkeys: g=baseline capture q=quit\n", flush=True)
|
||||
print(f" {'time':<14} {'event':<12} {'file':<40} {'Vpp':>7} {'mean':>7}")
|
||||
print(f" {'-'*14} {'-'*12} {'-'*40} {'-'*7} {'-'*7}")
|
||||
|
||||
last_pll = None
|
||||
unlock_count = 0
|
||||
baseline_count = 0
|
||||
err_count = 0
|
||||
|
||||
with KeyReader() as keys:
|
||||
while True:
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = sess.get(SN65_EP, timeout=HTTP_TO_S)
|
||||
r.raise_for_status()
|
||||
pll = pll_state(r.json())
|
||||
err_count = 0
|
||||
except Exception:
|
||||
pll = None
|
||||
err_count += 1
|
||||
|
||||
# Trigger Rigol on True → False (a real unlock). We ignore the
|
||||
# True → None case (transient I2C read failure) since it isn't
|
||||
# a PLL state change.
|
||||
if last_pll is True and pll is False:
|
||||
unlock_count += 1
|
||||
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
try:
|
||||
path, vpp, mean = capture_trace(
|
||||
rigol, f"unlock_{unlock_count:04d}")
|
||||
print(f" {iso:<14} {'UNLOCK':<12} "
|
||||
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
|
||||
flush=True)
|
||||
except Exception as e:
|
||||
print(f" {iso:<14} UNLOCK CAPTURE FAILED: {e}",
|
||||
flush=True)
|
||||
|
||||
last_pll = pll if pll is not None else last_pll
|
||||
|
||||
# Manual baseline capture
|
||||
key = keys.get_key()
|
||||
if key == "g":
|
||||
baseline_count += 1
|
||||
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
try:
|
||||
path, vpp, mean = capture_trace(
|
||||
rigol, f"baseline_{baseline_count:04d}")
|
||||
print(f" {iso:<14} {'BASELINE':<12} "
|
||||
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
|
||||
flush=True)
|
||||
except Exception as e:
|
||||
print(f" {iso:<14} BASELINE CAPTURE FAILED: {e}",
|
||||
flush=True)
|
||||
elif key == "q":
|
||||
_shutdown()
|
||||
|
||||
# Pace
|
||||
elapsed = time.time() - t0
|
||||
if elapsed < POLL_DT_S:
|
||||
time.sleep(POLL_DT_S - elapsed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
rebuild_eye.py
Normal 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.07–0.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||