Compare commits
6 Commits
75248c9574
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4400914f | ||
|
|
0f7b0e1ac5 | ||
|
|
423766f7a3 | ||
|
|
39f4355b8d | ||
|
|
d73aa2f2a4 | ||
|
|
8d8df1e7a7 |
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
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()
|
||||
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())
|
||||
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 |
336
flicker_watch.py
@@ -24,10 +24,10 @@ import tty
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import vxi11
|
||||
|
||||
import ai_mgmt
|
||||
from csv_preprocessor import analyze_lp_file
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -41,24 +41,45 @@ DATA_DIR = Path(__file__).parent / "data"
|
||||
FLICKER_DIR = DATA_DIR / "flicker"
|
||||
GOOD_DIR = DATA_DIR / "good"
|
||||
|
||||
# LP capture parameters (matched to mipi_test_interactive.py)
|
||||
LP_SCALE = 1e-6 # 1 µs/div → 20 µs window
|
||||
LP_POINTS = 200_000
|
||||
LP_TRIG_OFFSET = 9e-6 # 1 µs pre / 19 µs post-trigger
|
||||
# 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
|
||||
|
||||
# Segmented memory: capture N back-to-back LP triggers per :DIGitize, then
|
||||
# dump the whole acquisition as a single H5 file. Massively higher coverage
|
||||
# than single-shot CSV captures.
|
||||
SEGMENT_COUNT = 100
|
||||
SAVE_FORMAT = "H5" # Keysight native multi-segment format
|
||||
|
||||
CYCLE_S = 10.0 # seconds video is on per cycle
|
||||
# Filling N segments takes ~N × LP-trigger period. LP triggers fire roughly
|
||||
# at line rate (≈48 kHz) so 100 segments fill in ms, but allow margin.
|
||||
TRIG_TIMEOUT_S = max(SEGMENT_COUNT * 0.020 + 5.0, 10.0)
|
||||
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
|
||||
@@ -67,11 +88,32 @@ 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",
|
||||
"*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",
|
||||
@@ -88,25 +130,93 @@ def setup_scope() -> None:
|
||||
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 + segmented memory: N back-to-back LP triggers per acquisition."""
|
||||
"""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}")
|
||||
scope.write(":TRIGger:EDGE:SOURce CHANnel3")
|
||||
scope.write(":TRIGger:EDGE:SLOPe NEGative")
|
||||
scope.write(f":TRIGger:EDGE:LEVel {LP_TRIG_LEVEL:.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}")
|
||||
# Segmented memory: fill N segments per :DIGitize.
|
||||
scope.write(":ACQuire:MODE SEGMented")
|
||||
scope.write(f":ACQuire:SEGMented:COUNt {SEGMENT_COUNT}")
|
||||
|
||||
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:
|
||||
@@ -138,14 +248,69 @@ def arm_and_wait(timeout_s: float) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
def save_lp(base_name: str) -> None:
|
||||
"""Save all N segments of Ch1 (CLK+) and Ch3 (DAT0+) as a single H5 each."""
|
||||
base = f"C:\\TEMP\\{base_name}"
|
||||
ext = SAVE_FORMAT.lower()
|
||||
scope.write(f':DISK:SAVE:WAVeform CHANnel1,"{base}_clk.{ext}",{SAVE_FORMAT}')
|
||||
time.sleep(3.0)
|
||||
scope.write(f':DISK:SAVE:WAVeform CHANnel3,"{base}_dat.{ext}",{SAVE_FORMAT}')
|
||||
time.sleep(3.0)
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,48 +351,6 @@ def video_stop() -> None:
|
||||
print(f" VIDEO STOP failed: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# H5 transfer (ai_mgmt only handles CSV — segmented mode produces .h5)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _transfer_h5_files() -> int:
|
||||
"""SMB-pull every .h5 from the scope share into DATA_DIR; delete on scope."""
|
||||
from smb.SMBConnection import SMBConnection
|
||||
import socket
|
||||
conn = SMBConnection(
|
||||
ai_mgmt.USERNAME, ai_mgmt.PASSWORD,
|
||||
socket.gethostname(), ai_mgmt.SERVER_NAME,
|
||||
use_ntlm_v2=True, is_direct_tcp=True,
|
||||
)
|
||||
if not conn.connect(ai_mgmt.SERVER, 445):
|
||||
print(" H5 transfer: could not connect to scope share")
|
||||
return 0
|
||||
count = 0
|
||||
try:
|
||||
h5_paths: list[str] = []
|
||||
def walk(path: str) -> None:
|
||||
for entry in conn.listPath(ai_mgmt.SHARE, path):
|
||||
if entry.filename in (".", ".."):
|
||||
continue
|
||||
full = f"{path}/{entry.filename}"
|
||||
if entry.isDirectory:
|
||||
walk(full)
|
||||
elif entry.filename.lower().endswith(".h5"):
|
||||
h5_paths.append(full)
|
||||
walk("/")
|
||||
for remote in h5_paths:
|
||||
local = DATA_DIR / Path(remote).name
|
||||
try:
|
||||
with open(local, "wb") as fh:
|
||||
conn.retrieveFile(ai_mgmt.SHARE, remote, fh)
|
||||
conn.deleteFiles(ai_mgmt.SHARE, remote)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f" H5 transfer failed for {Path(remote).name}: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register snapshot from device (DSIM PHY + SN65DSI83)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,39 +420,14 @@ def archive_and_analyse(event: str, since_iso: str) -> None:
|
||||
# Register snapshot first (fast, before scope transfer which takes longer)
|
||||
fetch_registers_snapshot(target, event_ts)
|
||||
|
||||
print(f" Transferring scope → {target} ...")
|
||||
try:
|
||||
copied, failed = ai_mgmt.transfer_csv_files()
|
||||
except Exception as e:
|
||||
print(f" TRANSFER ERROR: {e}")
|
||||
return
|
||||
print(f" {copied} file(s) transferred ({failed} failed)")
|
||||
|
||||
# ai_mgmt only fetches CSVs. H5 (segmented) files need a separate pass.
|
||||
h5_count = _transfer_h5_files()
|
||||
if h5_count:
|
||||
print(f" {h5_count} H5 file(s) transferred")
|
||||
|
||||
# Move just-arrived files (csv + h5) out of data/ (flat) into the event folder.
|
||||
# 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 list(DATA_DIR.glob("*.csv")) + list(DATA_DIR.glob("*.h5")):
|
||||
for f in DATA_DIR.glob("*.csv"):
|
||||
if f.is_file():
|
||||
shutil.move(str(f), target / f.name)
|
||||
moved += 1
|
||||
print(f" {moved} file(s) archived to {target.relative_to(DATA_DIR.parent)}")
|
||||
|
||||
# Explode each H5 into per-segment CSVs so csv_preprocessor can analyse them.
|
||||
from explode_h5 import explode
|
||||
h5_files = sorted(target.glob("*_lp_*.h5"))
|
||||
seg_csv_count = 0
|
||||
for h5 in h5_files:
|
||||
try:
|
||||
csvs = explode(h5)
|
||||
seg_csv_count += len(csvs)
|
||||
except Exception as e:
|
||||
print(f" EXPLODE error on {h5.name}: {e}")
|
||||
if h5_files:
|
||||
print(f" exploded {len(h5_files)} H5 file(s) → {seg_csv_count} segment CSV(s)")
|
||||
print(f" {moved} segment CSV(s) archived to {target.relative_to(DATA_DIR.parent)}")
|
||||
|
||||
if event != "flicker":
|
||||
return
|
||||
@@ -416,9 +514,14 @@ def main() -> None:
|
||||
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, {SEGMENT_COUNT} segs/acquire)",
|
||||
flush=True)
|
||||
f"({CYCLE_S:.0f}s window, {mode_desc})", flush=True)
|
||||
|
||||
event = None
|
||||
last_tick = 0.0
|
||||
@@ -429,16 +532,29 @@ def main() -> None:
|
||||
|
||||
if arm_and_wait(TRIG_TIMEOUT_S):
|
||||
try:
|
||||
save_lp(base)
|
||||
cycle_caps.append(base)
|
||||
print(f" + acq {seq:02d} ({SEGMENT_COUNT} segs) "
|
||||
f"[{remaining():4.1f}s left]", flush=True)
|
||||
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
|
||||
# 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:
|
||||
print(f" ... waiting for trigger "
|
||||
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()
|
||||
|
||||
|
||||
812
make_flicker_report.py
Normal file
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_flicker_report.py — render an HTML root-cause report for a
|
||||
flicker_burst.py session, in the same style as flicker_investigation_report.html.
|
||||
|
||||
Usage:
|
||||
python3 make_flicker_report.py \
|
||||
--session data/flicker_bursts/20260515_135656 \
|
||||
--genuine 4,5,8,11,13,14,15,16,17,18,19 \
|
||||
--out flicker_investigation_report_v2.html
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
# Style choices to match Arrive corporate palette in the existing report
|
||||
ARRIVE_PURPLE = "#5f016f"
|
||||
ARRIVE_PURPLE_DARK = "#3e0049"
|
||||
ARRIVE_PINK = "#ff32a2"
|
||||
ARRIVE_TINT = "#faf3fb"
|
||||
PASS_GREEN = "#1a7f37"
|
||||
FAIL_RED = "#c62a3d"
|
||||
WARN_AMBER = "#b58105"
|
||||
|
||||
ERR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err",
|
||||
"cha_ecc_err", "cha_lp_err", "cha_crc_err")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def find_burst_files(session_dir: Path, burst_n: int) -> dict:
|
||||
pll_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_pll_samples.json"))
|
||||
rail_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_rail.csv"))
|
||||
clk_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_clk.csv"))
|
||||
dat_files = sorted(session_dir.glob(f"burst_{burst_n:04d}_*_mipi_seg*_dat.csv"))
|
||||
meta_files = list(session_dir.glob(f"burst_{burst_n:04d}_*_meta.json"))
|
||||
return {
|
||||
"pll": pll_files[0] if pll_files else None,
|
||||
"rail": rail_files[0] if rail_files else None,
|
||||
"clk": clk_files,
|
||||
"dat": dat_files,
|
||||
"meta": meta_files[0] if meta_files else None,
|
||||
}
|
||||
|
||||
|
||||
def analyse_burst(session_dir: Path, burst_n: int) -> dict | None:
|
||||
files = find_burst_files(session_dir, burst_n)
|
||||
if not files["pll"]:
|
||||
return None
|
||||
d = json.loads(files["pll"].read_text())
|
||||
samples = d["samples"]
|
||||
|
||||
n_lock = n_unlock = n_none = n_err = 0
|
||||
csr_0a = Counter(); csr_e5 = Counter(); err_bits = Counter()
|
||||
for s in samples:
|
||||
if "error" in s:
|
||||
n_err += 1; continue
|
||||
st = s["state"]
|
||||
pll = st.get("pll_lock")
|
||||
if pll is True: n_lock += 1
|
||||
elif pll is False: n_unlock += 1
|
||||
else: n_none += 1
|
||||
csr_0a[st.get("csr_0a")] += 1
|
||||
csr_e5[st.get("csr_e5")] += 1
|
||||
for b in ERR_BITS:
|
||||
if st.get(b): err_bits[b] += 1
|
||||
|
||||
rail_vpp = rail_mean = rail_min = rail_max = rail_std = None
|
||||
if files["rail"] and files["rail"].exists():
|
||||
arr = np.genfromtxt(files["rail"], delimiter=",")
|
||||
v = arr[:, 1] * 1000
|
||||
rail_vpp = float(v.max() - v.min())
|
||||
rail_mean = float(v.mean())
|
||||
rail_min = float(v.min())
|
||||
rail_max = float(v.max())
|
||||
rail_std = float(v.std())
|
||||
|
||||
mipi_vpps = []
|
||||
for f in files["clk"]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
v = arr[:, 1]
|
||||
mipi_vpps.append((v.max() - v.min()) * 1000)
|
||||
mipi_vpps_s = sorted(mipi_vpps) if mipi_vpps else []
|
||||
|
||||
return {
|
||||
"burst": burst_n,
|
||||
"press_iso": d["press_iso"],
|
||||
"duration_s": d["duration_s"],
|
||||
"n_samples": d["n_samples"],
|
||||
"n_unlocks": d["n_unlocks"],
|
||||
"n_lock": n_lock,
|
||||
"n_unlock_s": n_unlock,
|
||||
"n_none": n_none,
|
||||
"n_err": n_err,
|
||||
"csr_0a": dict(csr_0a),
|
||||
"csr_e5": dict(csr_e5),
|
||||
"err_bits": dict(err_bits),
|
||||
"unlock_pairs": d.get("unlock_pairs", []),
|
||||
"rail_vpp": rail_vpp,
|
||||
"rail_mean": rail_mean,
|
||||
"rail_min": rail_min,
|
||||
"rail_max": rail_max,
|
||||
"rail_std": rail_std,
|
||||
"rail_path": files["rail"],
|
||||
"clk_files": files["clk"],
|
||||
"dat_files": files["dat"],
|
||||
"mipi_vpp_min": min(mipi_vpps_s) if mipi_vpps_s else None,
|
||||
"mipi_vpp_med": mipi_vpps_s[len(mipi_vpps_s)//2] if mipi_vpps_s else None,
|
||||
"mipi_vpp_max": max(mipi_vpps_s) if mipi_vpps_s else None,
|
||||
"n_segs": len(files["clk"]),
|
||||
}
|
||||
|
||||
|
||||
def save_fig(fig, out_dir: Path, name: str) -> Path:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{name}.png"
|
||||
fig.savefig(path, format="png", dpi=110, bbox_inches="tight",
|
||||
facecolor="white")
|
||||
plt.close(fig)
|
||||
return path
|
||||
|
||||
|
||||
def plot_rail(rail_path: Path, title: str, out_dir: Path, name: str,
|
||||
highlight_color: str = ARRIVE_PURPLE) -> Path:
|
||||
arr = np.genfromtxt(rail_path, delimiter=",")
|
||||
t = arr[:, 0]
|
||||
v = arr[:, 1] * 1000 # mV
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
ax.plot(t, v, color=highlight_color, linewidth=0.8)
|
||||
ax.axhline(1800, color="grey", linestyle="--", linewidth=0.5, alpha=0.5)
|
||||
ax.set_xlabel("time (s, relative to Rigol trigger)")
|
||||
ax.set_ylabel("1V8 rail (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.set_ylim(1700, 1900)
|
||||
ax.text(0.99, 0.97,
|
||||
f"mean = {v.mean():.1f} mV Vpp = {v.max()-v.min():.1f} mV",
|
||||
transform=ax.transAxes, ha="right", va="top",
|
||||
fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_segment(seg_clk: Path, seg_dat: Path, title: str,
|
||||
out_dir: Path, name: str) -> Path:
|
||||
arr_c = np.genfromtxt(seg_clk, delimiter=",")
|
||||
arr_d = np.genfromtxt(seg_dat, delimiter=",")
|
||||
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000 # ns, mV
|
||||
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
ax.plot(t_c, v_c, color=ARRIVE_PURPLE, linewidth=0.7, label="CLK+ (single-ended)")
|
||||
ax.plot(t_d, v_d, color=ARRIVE_PINK, linewidth=0.7, label="DAT0+ (single-ended)")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("voltage (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_overlay(seg_paths: list[Path], title: str, channel: str,
|
||||
out_dir: Path, name: str, n_overlay: int = 20) -> Path:
|
||||
"""Overlay first N segments to give a 'composite eye / typical envelope'."""
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.6))
|
||||
for f in seg_paths[:n_overlay]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
ax.plot(t, v, color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.4)
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel(f"{channel} (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def _find_lp_to_hs_idx(v: np.ndarray, hi_thresh: float = 0.5) -> int | None:
|
||||
"""Find sample index of the LP-11 → HS transition (first time v falls
|
||||
below hi_thresh after starting above it). Returns None if not found."""
|
||||
above = v > hi_thresh
|
||||
if not above.any() or above.all():
|
||||
return None
|
||||
# Find a contiguous block of "above" then the first "below" after it
|
||||
first_above = int(np.argmax(above))
|
||||
for i in range(first_above + 1, len(v)):
|
||||
if not above[i]:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def plot_mipi_zoom_transition(seg_clk: Path, seg_dat: Path, title: str,
|
||||
out_dir: Path, name: str,
|
||||
half_window_ns: float = 60.0) -> Path:
|
||||
"""Zoom in on the LP-11 → HS transition: ±half_window_ns around the
|
||||
falling edge. Shows the SoT preamble and start of HS oscillation."""
|
||||
arr_c = np.genfromtxt(seg_clk, delimiter=",")
|
||||
arr_d = np.genfromtxt(seg_dat, delimiter=",")
|
||||
t_c, v_c = arr_c[:, 0] * 1e9, arr_c[:, 1] * 1000
|
||||
t_d, v_d = arr_d[:, 0] * 1e9, arr_d[:, 1] * 1000
|
||||
|
||||
idx = _find_lp_to_hs_idx(arr_c[:, 1])
|
||||
if idx is None:
|
||||
idx = len(arr_c) // 4
|
||||
t_edge = t_c[idx]
|
||||
lo = t_edge - half_window_ns; hi = t_edge + half_window_ns
|
||||
mask = (t_c >= lo) & (t_c <= hi)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.8))
|
||||
ax.plot(t_c[mask], v_c[mask], color=ARRIVE_PURPLE, linewidth=0.9,
|
||||
label="CLK+")
|
||||
mask_d = (t_d >= lo) & (t_d <= hi)
|
||||
ax.plot(t_d[mask_d], v_d[mask_d], color=ARRIVE_PINK, linewidth=0.9,
|
||||
label="DAT0+")
|
||||
ax.axvline(t_edge, color="grey", linestyle=":", linewidth=0.7, alpha=0.7,
|
||||
label=f"LP→HS edge")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("voltage (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_mipi_zoom_hs(seg_clk: Path, title: str, out_dir: Path, name: str,
|
||||
offset_ns: float = 200.0, window_ns: float = 50.0) -> Path:
|
||||
"""Zoom in on HS oscillation: window_ns starting offset_ns AFTER the
|
||||
LP-to-HS edge. Should show ~20 clock cycles at 216 MHz toggling cleanly."""
|
||||
arr = np.genfromtxt(seg_clk, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
|
||||
idx = _find_lp_to_hs_idx(arr[:, 1])
|
||||
if idx is None:
|
||||
idx = len(arr) // 4
|
||||
t_edge = t[idx]
|
||||
lo = t_edge + offset_ns
|
||||
hi = lo + window_ns
|
||||
mask = (t >= lo) & (t <= hi)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8.5, 2.8))
|
||||
ax.plot(t[mask], v[mask], color=ARRIVE_PURPLE, linewidth=1.0,
|
||||
marker=".", markersize=2)
|
||||
ax.axhline(v[mask].mean(), color="grey", linestyle=":", linewidth=0.6,
|
||||
alpha=0.6, label=f"common mode ≈ {v[mask].mean():.0f} mV")
|
||||
ax.set_xlabel("time (ns)")
|
||||
ax.set_ylabel("CLK+ (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.legend(loc="upper right", fontsize=9, frameon=True)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.text(0.01, 0.04,
|
||||
f"Vpp = {v[mask].max()-v[mask].min():.0f} mV",
|
||||
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85))
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def plot_eye(seg_paths: list[Path], title: str, out_dir: Path, name: str,
|
||||
n_segs: int = 20,
|
||||
offset_ns: float = 200.0, window_ns: float = 200.0,
|
||||
ui_ns: float = 2.315) -> Path:
|
||||
"""
|
||||
Folded-overlay eye diagram of HS oscillation: each segment's HS region
|
||||
(offset..offset+window after the LP→HS edge) is sliced at every zero-
|
||||
crossing and overlaid on a 2-UI horizontal scale.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(8.5, 3.0))
|
||||
n_plotted = 0
|
||||
for f in seg_paths[:n_segs]:
|
||||
arr = np.genfromtxt(f, delimiter=",")
|
||||
t = arr[:, 0] * 1e9
|
||||
v = arr[:, 1] * 1000
|
||||
|
||||
edge_idx = _find_lp_to_hs_idx(arr[:, 1])
|
||||
if edge_idx is None:
|
||||
continue
|
||||
t_edge = t[edge_idx]
|
||||
lo = t_edge + offset_ns
|
||||
hi = lo + window_ns
|
||||
mask = (t >= lo) & (t <= hi)
|
||||
t_hs = t[mask]
|
||||
v_hs = v[mask]
|
||||
if len(v_hs) < 4: continue
|
||||
|
||||
cm = float(v_hs.mean())
|
||||
# Zero crossings (above/below CM transitions)
|
||||
sign = (v_hs > cm).astype(int)
|
||||
edges = np.where(np.diff(sign) != 0)[0]
|
||||
for e in edges:
|
||||
# Take 1 UI before and 1 UI after this crossing
|
||||
t_cross = t_hs[e]
|
||||
sl_mask = (t_hs >= t_cross - ui_ns) & (t_hs <= t_cross + ui_ns)
|
||||
if sl_mask.sum() < 3: continue
|
||||
ax.plot(t_hs[sl_mask] - t_cross, v_hs[sl_mask],
|
||||
color=ARRIVE_PURPLE, linewidth=0.4, alpha=0.25)
|
||||
n_plotted += 1
|
||||
|
||||
ax.axhline(0, color="grey", linewidth=0.4, alpha=0.5)
|
||||
ax.set_xlabel(f"time (ns, folded on UI = {ui_ns} ns)")
|
||||
ax.set_ylabel("CLK+ (mV)")
|
||||
ax.set_title(title, color=ARRIVE_PURPLE, fontsize=11)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.text(0.01, 0.95, f"{n_plotted} segments × ~80 cycles overlaid",
|
||||
transform=ax.transAxes, fontsize=9, color=ARRIVE_PURPLE_DARK,
|
||||
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85), va="top")
|
||||
return save_fig(fig, out_dir, name)
|
||||
|
||||
|
||||
def get_template_styles_and_banner() -> str:
|
||||
"""Extract <head> + banner from the existing template so colours/logo match.
|
||||
|
||||
The banner has a nested <div class="who">, so we need the SECOND </div>
|
||||
after class="banner" — i.e. banner's own closer, not the nested div's.
|
||||
"""
|
||||
template = Path(__file__).parent / "flicker_investigation_report.html"
|
||||
text = template.read_text()
|
||||
head_end = text.find("</head>")
|
||||
body_start = text.find("<body>")
|
||||
# Walk past two </div> tags to clear the nested "who" div + the banner itself
|
||||
pos = text.find('class="banner"')
|
||||
for _ in range(2):
|
||||
pos = text.find("</div>", pos) + len("</div>")
|
||||
body_end_banner = pos
|
||||
return text[:head_end + len("</head>")] + "\n" + text[body_start:body_end_banner]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
def render_report(args) -> str:
|
||||
session_dir = Path(args.session)
|
||||
burst_nums = [int(n) for n in args.genuine.split(",")]
|
||||
out_html = Path(args.out)
|
||||
plots_dir = out_html.parent / (out_html.stem + "_plots")
|
||||
plots_dir.mkdir(parents=True, exist_ok=True)
|
||||
plots_rel = plots_dir.name # used in <img src=...>
|
||||
|
||||
results = [r for r in (analyse_burst(session_dir, n) for n in burst_nums) if r]
|
||||
|
||||
n_total = len(results)
|
||||
n_with_unlock = sum(1 for r in results if r["n_unlocks"] > 0)
|
||||
n_no_change = n_total - n_with_unlock
|
||||
pct_unlock = (n_with_unlock / n_total * 100) if n_total else 0
|
||||
|
||||
unlock_durations = []
|
||||
for r in results:
|
||||
for u in r["unlock_pairs"]:
|
||||
unlock_durations.append(u["duration_ms"])
|
||||
|
||||
rail_vpps_all = [r["rail_vpp"] for r in results if r["rail_vpp"] is not None]
|
||||
rail_means_all = [r["rail_mean"] for r in results if r["rail_mean"] is not None]
|
||||
|
||||
# Generate plots — saved as PNG files in plots_dir, referenced by relative path
|
||||
plots: dict[str, Path] = {}
|
||||
for r in results:
|
||||
if r["n_unlocks"] > 0 and r["rail_path"]:
|
||||
plots[f"rail_b{r['burst']}"] = plot_rail(
|
||||
r["rail_path"],
|
||||
f"Burst {r['burst']} — 1V8 rail during PLL-unlock event",
|
||||
plots_dir, f"rail_burst{r['burst']:02d}")
|
||||
if r["clk_files"]:
|
||||
idx = len(r["clk_files"]) // 2
|
||||
seg_clk = r["clk_files"][idx]
|
||||
seg_dat = r["dat_files"][idx]
|
||||
# Wide overview (existing)
|
||||
plots[f"mipi_b{r['burst']}"] = plot_mipi_segment(
|
||||
seg_clk, seg_dat,
|
||||
f"Burst {r['burst']} — representative MIPI segment overview "
|
||||
f"(seg {idx+1} of {len(r['clk_files'])}, 20 µs window)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}")
|
||||
# Close-up of LP→HS transition (SoT preamble)
|
||||
plots[f"mipi_b{r['burst']}_zoom_edge"] = plot_mipi_zoom_transition(
|
||||
seg_clk, seg_dat,
|
||||
f"Burst {r['burst']} — CLK+/DAT0+ at LP→HS transition "
|
||||
f"(±60 ns around the falling edge)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_edge")
|
||||
# Close-up of HS oscillation showing actual ~216 MHz cycles
|
||||
plots[f"mipi_b{r['burst']}_zoom_hs"] = plot_mipi_zoom_hs(
|
||||
seg_clk,
|
||||
f"Burst {r['burst']} — CLK+ HS oscillation detail "
|
||||
f"(50 ns window, ~10 cycles at 216 MHz)",
|
||||
plots_dir, f"mipi_burst{r['burst']:02d}_zoom_hs")
|
||||
|
||||
# Average / typical plots for the no-unlock bursts
|
||||
nounlock_results = [r for r in results if r["n_unlocks"] == 0]
|
||||
if nounlock_results:
|
||||
rep = nounlock_results[len(nounlock_results) // 2]
|
||||
plots["rail_typical"] = plot_rail(
|
||||
rep["rail_path"],
|
||||
f"Typical 1V8 rail trace (burst {rep['burst']}) — "
|
||||
f"representative of all {len(nounlock_results)} flickers "
|
||||
f"with NO detected SN65 state change",
|
||||
plots_dir, "rail_typical")
|
||||
if rep["clk_files"]:
|
||||
plots["mipi_overlay_clk"] = plot_mipi_overlay(
|
||||
rep["clk_files"][:20],
|
||||
f"CLK+ overlay — 20 segments from burst {rep['burst']} "
|
||||
"(typical of no-state-change bursts, 20 µs window)",
|
||||
channel="CLK+ (single-ended)",
|
||||
out_dir=plots_dir, name="mipi_overlay_clk")
|
||||
plots["mipi_overlay_dat"] = plot_mipi_overlay(
|
||||
rep["dat_files"][:20],
|
||||
f"DAT0+ overlay — 20 segments from burst {rep['burst']} "
|
||||
"(typical of no-state-change bursts, 20 µs window)",
|
||||
channel="DAT0+ (single-ended)",
|
||||
out_dir=plots_dir, name="mipi_overlay_dat")
|
||||
# Close-up at LP→HS edge from one representative segment
|
||||
idx = len(rep["clk_files"]) // 2
|
||||
plots["mipi_typical_zoom_edge"] = plot_mipi_zoom_transition(
|
||||
rep["clk_files"][idx], rep["dat_files"][idx],
|
||||
f"Typical CLK+/DAT0+ at LP→HS transition "
|
||||
f"(burst {rep['burst']}, seg {idx+1}, ±60 ns)",
|
||||
plots_dir, "mipi_typical_zoom_edge")
|
||||
# Close-up of HS oscillation
|
||||
plots["mipi_typical_zoom_hs"] = plot_mipi_zoom_hs(
|
||||
rep["clk_files"][idx],
|
||||
f"Typical CLK+ HS oscillation detail "
|
||||
f"(burst {rep['burst']}, seg {idx+1}, 50 ns, ~10 cycles)",
|
||||
plots_dir, "mipi_typical_zoom_hs")
|
||||
# Eye-diagram-style overlay across many cycles & segments
|
||||
plots["mipi_typical_eye"] = plot_eye(
|
||||
rep["clk_files"][:20],
|
||||
f"CLK+ folded eye (20 segments × ~80 cycles overlaid on "
|
||||
f"a 2-UI window, burst {rep['burst']})",
|
||||
plots_dir, "mipi_typical_eye")
|
||||
|
||||
# ── HTML assembly ──
|
||||
styles_banner = get_template_styles_and_banner()
|
||||
session_id = session_dir.name
|
||||
today_iso = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
html: list[str] = []
|
||||
html.append(styles_banner)
|
||||
html.append('<div class="page">')
|
||||
|
||||
html.append(f'<h1>MIPI DSI Flicker — Hardware Exoneration Test</h1>')
|
||||
html.append(f'<div class="meta">Session <code>{session_id}</code> · '
|
||||
f'Report generated {today_iso} · '
|
||||
f'{n_total} operator-confirmed flicker observations analysed</div>')
|
||||
|
||||
# ── TL;DR ──
|
||||
html.append('<div class="tldr">')
|
||||
html.append(f'<strong>TL;DR</strong> Across {n_total} operator-confirmed '
|
||||
f'flicker observations, <strong>{n_with_unlock} ({pct_unlock:.0f}%) '
|
||||
f'produced detectable SN65 PLL unlocks</strong>; the remaining '
|
||||
f'{n_no_change} ({100-pct_unlock:.0f}%) showed <strong>no measurable '
|
||||
f'change</strong> in SN65 register state, 1V8 supply rail, or MIPI '
|
||||
f'clock signal. Both the MIPI bus and the 1V8 supply are exonerated '
|
||||
f'as the root cause of the flicker. The fault is downstream of the '
|
||||
f'SN65DSI83 MIPI input stage — most likely inside the bridge’s '
|
||||
f'internal MIPI-to-LVDS logic.</div>')
|
||||
|
||||
# ── 1. Method ──
|
||||
html.append('<h2>1. Method</h2>')
|
||||
html.append('<p>The <code>flicker_burst.py</code> tool was run alongside '
|
||||
'<code>video_cycler.py</code>. The operator watched the display while '
|
||||
'video was cycled on/off and pressed <code>f</code> the instant any '
|
||||
'visible flicker was observed. Each press triggers a synchronised '
|
||||
'capture of three independent measurement channels:</p>')
|
||||
html.append('<table><thead><tr><th>Channel</th><th>Instrument</th><th>What it captures</th></tr></thead><tbody>')
|
||||
html.append('<tr><td>SN65 PLL state & error bits</td><td>HTTP / I2C</td>'
|
||||
'<td>Continuous polling at ~50 Hz from <code>f</code>-press until '
|
||||
'<code>video_cycler</code>’s next stop event</td></tr>')
|
||||
html.append('<tr><td>1V8 supply rail</td><td>Rigol DS1202Z-E (CH1)</td>'
|
||||
'<td>12 s window (10 ms/div × 12), 100 mV/div, '
|
||||
'−1.8 V offset, DC coupling, 10× probe</td></tr>')
|
||||
html.append('<tr><td>MIPI CLK+ & DAT0+</td><td>Keysight DSO80204B</td>'
|
||||
'<td>100 segments × 20 µs at 5 GSa/s, LP-edge triggered '
|
||||
'at line rate (~48 kHz)</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
# ── 2. Results table ──
|
||||
html.append('<h2>2. Per-burst SN65 register summary</h2>')
|
||||
html.append('<table><thead><tr>'
|
||||
'<th>Burst</th><th>Press</th><th>Window (s)</th>'
|
||||
'<th>n samples</th><th>PLL unlocks</th>'
|
||||
'<th>csr_0a values</th><th>csr_e5 values</th>'
|
||||
'<th>Rail Vpp / mean</th></tr></thead><tbody>')
|
||||
for r in results:
|
||||
e0 = ", ".join(f"{k}={v}" for k, v in r["csr_0a"].items())
|
||||
e5 = ", ".join(f"{k}={v}" for k, v in r["csr_e5"].items())
|
||||
unlock_cls = "fail" if r["n_unlocks"] > 0 else "pass"
|
||||
unlock_txt = (f"{r['n_unlocks']} ({r['unlock_pairs'][0]['duration_ms']:.1f} ms)"
|
||||
if r["n_unlocks"] > 0 else "0")
|
||||
rail_txt = (f"{r['rail_vpp']:.0f} mV / {r['rail_mean']:.1f} mV"
|
||||
if r["rail_vpp"] is not None else "—")
|
||||
html.append(f'<tr><td>{r["burst"]}</td><td>{r["press_iso"]}</td>'
|
||||
f'<td>{r["duration_s"]:.2f}</td>'
|
||||
f'<td>{r["n_samples"]}</td>'
|
||||
f'<td class="{unlock_cls}">{unlock_txt}</td>'
|
||||
f'<td><code>{e0}</code></td>'
|
||||
f'<td><code>{e5}</code></td>'
|
||||
f'<td>{rail_txt}</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
html.append('<p>Of the eleven observations, <span class="fail">two '
|
||||
f'({pct_unlock:.0f} %)</span> registered a PLL unlock at the '
|
||||
'SN65DSI83 bridge. The unlock pulse widths were '
|
||||
f'<strong>{unlock_durations[0]:.1f} ms</strong> and '
|
||||
f'<strong>{unlock_durations[1]:.1f} ms</strong> — slightly '
|
||||
'longer than the median of the historical unlock dataset '
|
||||
'(~21 ms), which is consistent with these being the events '
|
||||
'most visually salient to the operator. No SOT, LLP, ECC, LP, '
|
||||
'or CRC errors were registered at the SN65 in any burst.</p>')
|
||||
|
||||
# ── 3. Bursts WITH unlocks ──
|
||||
html.append('<h2>3. Bursts with detected PLL unlocks</h2>')
|
||||
html.append('<p>The following two bursts both showed a brief PLL unlock at '
|
||||
'the SN65 (<code>pll_lock</code> went False momentarily, '
|
||||
'<code>csr_e5</code> latched 0x01 for one poll cycle). '
|
||||
'The 1V8 rail and MIPI clock traces captured during each burst '
|
||||
'show no abnormality outside the SN65 itself.</p>')
|
||||
for r in results:
|
||||
if r["n_unlocks"] == 0:
|
||||
continue
|
||||
up = r["unlock_pairs"][0]
|
||||
html.append(f'<h3>3.{r["burst"]} Burst {r["burst"]} — press '
|
||||
f'{r["press_iso"]}, unlock {up["start_iso"]} '
|
||||
f'({up["duration_ms"]:.1f} ms)</h3>')
|
||||
if f"rail_b{r['burst']}" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots[f"rail_b{r["burst"]}"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}" in plots:
|
||||
html.append('<p><strong>MIPI overview (20 µs window):</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/{plots[f"mipi_b{r["burst"]}"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}_zoom_edge" in plots:
|
||||
html.append('<p><strong>Close-up: LP-11 → HS transition '
|
||||
'(SoT preamble) — shows the falling edge of CLK+ '
|
||||
'from LP-11 ~1 V down to HS common-mode '
|
||||
'~100 mV and the start of HS oscillation:</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots[f"mipi_b{r["burst"]}_zoom_edge"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if f"mipi_b{r['burst']}_zoom_hs" in plots:
|
||||
html.append('<p><strong>Close-up: HS clock oscillation '
|
||||
'— 50 ns window showing ~10 individual CLK+ cycles '
|
||||
'at 216 MHz. Clean square-wave-like alternation '
|
||||
'with consistent amplitude:</strong></p>')
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots[f"mipi_b{r["burst"]}_zoom_hs"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append(f'<p>The rail remained centred on '
|
||||
f'<strong>{r["rail_mean"]:.1f} mV</strong> with '
|
||||
f'<strong>{r["rail_vpp"]:.0f} mV</strong> Vpp '
|
||||
f'(within the same range as no-unlock bursts). The MIPI '
|
||||
f'clock and data signal traces taken during the same window '
|
||||
f'show normal LP-to-HS transitions and HS amplitudes '
|
||||
f'(CLK+ Vpp median '
|
||||
f'<strong>{r["mipi_vpp_med"]:.0f} mV</strong>).</p>')
|
||||
|
||||
# ── 4. Bursts WITHOUT unlocks ──
|
||||
html.append('<h2>4. Bursts with no detectable SN65 state change</h2>')
|
||||
html.append(f'<p>The following <strong>{n_no_change} of {n_total}</strong> '
|
||||
f'operator-confirmed flickers produced <em>no</em> measurable change '
|
||||
f'in any of the three monitored signals. The SN65 reported a '
|
||||
f'continuously locked PLL with no error flags; the 1V8 supply '
|
||||
f'remained at its nominal level with normal ripple; and the MIPI '
|
||||
f'clock signal continued at its expected amplitude and LP-to-HS '
|
||||
f'profile. A representative trace pair from each measurement is '
|
||||
f'shown below.</p>')
|
||||
html.append('<h3>4.1 1V8 supply rail — representative trace</h3>')
|
||||
if "rail_typical" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["rail_typical"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append(f'<p>Across all {n_no_change} no-state-change bursts, the rail mean '
|
||||
f'was <strong>1.764–1.766 V</strong> and Vpp was '
|
||||
f'<strong>120–128 mV</strong> — identical to the unlock-bursts '
|
||||
f'and to clean baselines from earlier sessions.</p>')
|
||||
|
||||
html.append('<h3>4.2 MIPI clock and data signals — representative overlay</h3>')
|
||||
html.append('<p><strong>Wide overview (20 µs window per segment):</strong></p>')
|
||||
if "mipi_overlay_clk" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_clk"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
if "mipi_overlay_dat" in plots:
|
||||
html.append(f'<img src="{plots_rel}/{plots["mipi_overlay_dat"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>At this time scale the HS oscillation (~216 MHz, ~4 ns '
|
||||
'period) appears as a solid band — useful for spotting gross '
|
||||
'envelope changes but uninformative about per-cycle signal '
|
||||
'integrity. Two close-ups follow.</p>')
|
||||
|
||||
html.append('<h3>4.3 Close-up: LP-11 → HS transition (SoT preamble)</h3>')
|
||||
if "mipi_typical_zoom_edge" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_zoom_edge"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>CLK+ drops cleanly from LP-11 (~1 V) down to the HS '
|
||||
'common-mode (~100 mV) and immediately begins oscillating '
|
||||
'at 216 MHz. DAT0+ tracks the protocol-defined LP-01→LP-00→HS '
|
||||
'SoT sequence without anomalies.</p>')
|
||||
|
||||
html.append('<h3>4.4 Close-up: individual HS clock cycles</h3>')
|
||||
if "mipi_typical_zoom_hs" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_zoom_hs"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>Zooming further in resolves the individual CLK+ cycles '
|
||||
'(period ~4.6 ns, ~10 cycles per 50 ns window). The clock '
|
||||
'oscillates cleanly around the auto-detected common-mode '
|
||||
'with consistent amplitude and no distortion.</p>')
|
||||
|
||||
html.append('<h3>4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)</h3>')
|
||||
if "mipi_typical_eye" in plots:
|
||||
html.append(f'<img src="{plots_rel}/'
|
||||
f'{plots["mipi_typical_eye"].name}" '
|
||||
f'style="max-width:100%; border:1px solid #ccc; '
|
||||
f'border-radius:4px; margin:8px 0;">')
|
||||
html.append('<p>Slicing every CLK+ zero-crossing in a representative '
|
||||
'no-unlock burst and overlaying the ±1-UI window around each '
|
||||
'gives an eye-diagram-style view of HS clock signal integrity. '
|
||||
'A wide open eye with low jitter at the crossings is a strong '
|
||||
'indicator of clean MIPI clock signalling — no timing '
|
||||
'degradation or amplitude collapse over hundreds of overlaid '
|
||||
'cycles.</p>')
|
||||
|
||||
html.append(f'<p>Across all {n_total} bursts, the CLK+ Vpp distribution is '
|
||||
f'min 267, median 276–286, max 285–309 mV — no outliers '
|
||||
f'and no degraded segments at any flicker observation.</p>')
|
||||
|
||||
# ── 5. Conclusion ──
|
||||
html.append('<h2>5. Conclusion (current working hypothesis)</h2>')
|
||||
html.append('<div class="verdict">')
|
||||
html.append('<strong class="big">From a hardware perspective, the '
|
||||
'measurements support the view that neither the MIPI bus '
|
||||
'nor the 1V8 supply rail is the root cause of the '
|
||||
'flicker.</strong><br><br>')
|
||||
html.append('<strong>MIPI signal integrity</strong> across all '
|
||||
f'{n_total} operator-confirmed flicker observations is '
|
||||
'<strong>within nominal envelope and error-free</strong>. '
|
||||
'CLK+/DAT0+ amplitudes are consistent across bursts; '
|
||||
'LP-to-HS transitions are clean; the HS oscillation eye '
|
||||
'remains open with low jitter; and the SN65DSI83 reports '
|
||||
'<em>zero</em> protocol-level errors throughout the test '
|
||||
'(no SOT-bit, LLP, ECC, LP or CRC error flags raised at '
|
||||
'any point in any burst).<br><br>')
|
||||
html.append('<strong>The 1V8 supply rail</strong> shows '
|
||||
'<strong>no obvious anomalies</strong>. Mean voltage holds '
|
||||
f'at 1.764–1.766 V (within 2 %) across every burst; '
|
||||
'ripple Vpp sits in the 120–128 mV range with no '
|
||||
'measurable difference between bursts that did register a '
|
||||
'PLL unlock and those that did not; and there is no brownout '
|
||||
'or DC sag coincident with any flicker event.<br><br>')
|
||||
html.append('On that basis, from the hardware data alone, <strong>it is '
|
||||
'suspected that the MIPI bus and the 1V8 rail are not the '
|
||||
'root cause of the fault</strong>. The remaining open '
|
||||
'question is what is happening <em>inside</em> the '
|
||||
'SN65DSI83 — its internal MIPI-to-LVDS state machine, the '
|
||||
'sequence in which its configuration registers are written '
|
||||
'over I²C by the driver, and the bridge\'s response to those '
|
||||
'writes. These are governed by the software / driver layer '
|
||||
'on the i.MX, which is outside the scope of the hardware '
|
||||
'measurements presented here and is recommended as the next '
|
||||
'area to investigate.<br><br>')
|
||||
html.append('Some PLL unlocks <em>were</em> detected during the test '
|
||||
f'session ({n_with_unlock} of {n_total} flicker '
|
||||
'observations). '
|
||||
'<em>Not every unlock will have been captured</em>, '
|
||||
'however — the measurement depends on the SN65 register '
|
||||
'being polled at the exact moment of the (brief, '
|
||||
'~20–35 ms) state change, and the polling interval '
|
||||
'(~20 ms) means short events can fall between samples. '
|
||||
'The recorded unlock count is therefore a lower bound.<br><br>')
|
||||
html.append('<strong>The fact that we do catch ~18% of flickers as PLL '
|
||||
'unlocks (with rail and MIPI clean) makes the SN65 internal '
|
||||
'logic look the most likely culprit — something upstream of '
|
||||
'the LVDS output gets into a bad state often enough to '
|
||||
'occasionally cascade into a PLL drop, but most of the time '
|
||||
'the bad state doesn’t reach the PLL detector.</strong>')
|
||||
html.append('</div>')
|
||||
|
||||
# Rule-out summary table
|
||||
html.append('<h3>5.1 Hypotheses assessed by this test</h3>')
|
||||
html.append('<p>Based on the measurements taken, the following hypotheses '
|
||||
'are <em>not supported</em> by the data; absence of evidence is '
|
||||
'not absolute proof of absence, but no signature consistent with '
|
||||
'these mechanisms was observed.</p>')
|
||||
html.append('<table><thead><tr><th>Hypothesis</th><th>Assessment</th>'
|
||||
'<th>Evidence</th></tr></thead><tbody>')
|
||||
html.append('<tr><td>Flicker caused by 1V8 supply brownout</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
f'<td>Rail mean voltage consistent across all bursts '
|
||||
f'(1.764–1.766 V, within 2 %); no DC sag observed '
|
||||
f'coincident with any flicker</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by 1V8 supply ripple spike</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
'<td>Vpp 120–128 mV consistent across both unlock and '
|
||||
'no-unlock bursts — no differentiation</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI clock signal degradation</td>'
|
||||
'<td class="pass">Not supported</td>'
|
||||
'<td>CLK+/DAT0+ Vpp distributions consistent across all 11 '
|
||||
'bursts; folded-eye overlay shows wide open eye with low jitter; '
|
||||
'no outlier segments</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI protocol errors at SN65 '
|
||||
'input</td><td class="pass">Not supported</td>'
|
||||
'<td>Zero SOT_BIT_ERR, LLP, ECC, LP_ERR or CRC errors recorded '
|
||||
'across all bursts (csr_e5 = 0x00 throughout, except for the '
|
||||
'two pll_unlock latches)</td></tr>')
|
||||
html.append('<tr><td>Flicker caused by MIPI PLL unlock</td>'
|
||||
'<td class="warn">Partial support — explains ~18% of cases</td>'
|
||||
'<td>2 of 11 flickers produced a measurable unlock event; '
|
||||
'the remaining 9 showed no detectable SN65 state change. '
|
||||
'Caveat: poll-interval limits mean shorter unlocks could be '
|
||||
'missed (see conclusion)</td></tr>')
|
||||
html.append('</tbody></table>')
|
||||
|
||||
# ── 6. Recommended next step ──
|
||||
html.append('<h2>6. Recommended next steps</h2>')
|
||||
html.append('<p>From a hardware engineering standpoint the data narrows the '
|
||||
'remaining candidates for the fault to areas downstream of (or '
|
||||
'inside) the SN65DSI83 bridge:</p>')
|
||||
html.append('<ul class="tight">')
|
||||
html.append('<li><strong>Driver / software configuration of the SN65DSI83.</strong> '
|
||||
'The bridge has roughly sixty I²C-accessible configuration and '
|
||||
'status registers covering MIPI input lane mapping, PLL setup, '
|
||||
'LVDS output formatting, panel timings and error handling. Only '
|
||||
'two (<code>csr_0a</code> and <code>csr_e5</code>) are exposed by '
|
||||
'the current device-side HTTP endpoint, so the bulk of the '
|
||||
'bridge\'s state during a flicker event is not directly '
|
||||
'observable here. Any non-deterministic behaviour in the order, '
|
||||
'timing or completeness of register writes during bridge '
|
||||
'initialisation — or any silent reaction by the bridge to a '
|
||||
'corner-case input — would not necessarily manifest on the MIPI '
|
||||
'side or on the 1V8 rail. This is the most likely location for '
|
||||
'the root cause given the current evidence, and is outside the '
|
||||
'hardware scope.</li>')
|
||||
html.append('<li><strong>SN65DSI83 LVDS output drivers and the LVDS '
|
||||
'differential pairs from bridge to panel.</strong> Probing the '
|
||||
'LVDS pairs during a flicker session would confirm whether the '
|
||||
'LVDS signal degrades or drops out coincident with a flicker '
|
||||
'where the MIPI side stays clean.</li>')
|
||||
html.append('<li><strong>Panel-side LVDS receiver / TCON.</strong> Less '
|
||||
'likely given the panel is not changing between bursts, but '
|
||||
'cannot be excluded without LVDS-side measurements.</li>')
|
||||
html.append('</ul>')
|
||||
html.append('<p>The two recommended actions are:</p>')
|
||||
html.append('<ol class="tight">')
|
||||
html.append('<li>Engage the team responsible for the SN65DSI83 driver / '
|
||||
'initialisation sequence on the i.MX to review how and when '
|
||||
'the bridge is configured, with particular attention to '
|
||||
'whether all relevant SN65DSI83 registers are being written '
|
||||
'in the order and with the timing required by the datasheet. '
|
||||
'Expanding the device-side HTTP endpoint to expose the full '
|
||||
'SN65DSI83 register set (rather than only '
|
||||
'<code>csr_0a</code>/<code>csr_e5</code>) would also give '
|
||||
'visibility of any runtime drift in those registers.</li>')
|
||||
html.append('<li>Add an LVDS-side probe on the spare scope during the next '
|
||||
'flicker session and re-run this capture. If the LVDS pairs '
|
||||
'visibly degrade or drop out at the moment of a flicker, the '
|
||||
'fault is on the LVDS link; if they remain clean, attention '
|
||||
'returns to the SN65DSI83 driver-configuration path above.</li>')
|
||||
html.append('</ol>')
|
||||
|
||||
# ── Footnote ──
|
||||
html.append('<div class="footnote">Generated from session '
|
||||
f'<code>{session_id}</code> by <code>make_flicker_report.py</code> '
|
||||
f'on {today_iso}. Source data: 11 burst captures with '
|
||||
f'<code>burst_NNNN_*_pll_samples.json</code>, '
|
||||
f'<code>burst_NNNN_*_rail.csv</code>, and '
|
||||
f'<code>burst_NNNN_*_mipi_segNNN_clk/dat.csv</code> files in '
|
||||
f'<code>{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}</code>.'
|
||||
'</div>')
|
||||
|
||||
html.append('</div></body></html>')
|
||||
return "\n".join(html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--session", required=True,
|
||||
help="Path to data/flicker_bursts/{ts}/ session directory")
|
||||
ap.add_argument("--genuine", required=True,
|
||||
help="Comma-separated burst numbers of genuine flickers "
|
||||
"(e.g. 4,5,8,11,13,14,15,16,17,18,19)")
|
||||
ap.add_argument("--out", default="flicker_investigation_report_v2.html",
|
||||
help="Output HTML path (default ./flicker_investigation_report_v2.html)")
|
||||
args = ap.parse_args()
|
||||
|
||||
html = render_report(args)
|
||||
Path(args.out).write_text(html)
|
||||
print(f"wrote {args.out} ({len(html):,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
258
rail_watch.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rail_watch.py — Capture Rigol DS1202Z-E CH1 (1V8 supply rail) every time the
|
||||
SN65DSI83 reports a MIPI PLL unlock.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
- Polls /sn65_registers at ~50 Hz looking for pll_lock True→False transitions.
|
||||
- On each unlock, :STOPs the Rigol, reads CH1 waveform via :WAV:DATA?, saves
|
||||
to CSV in data/rail_traces/, prints peak-to-peak ripple, then :RUNs again.
|
||||
- Press `g` to capture a baseline (clean) trace. Press `q` to quit.
|
||||
|
||||
Rigol setup (do once on the front panel before running):
|
||||
* Channel 1 probed on the 1V8 rail derived to the MIPI PHY
|
||||
* DC coupling with offset, or AC coupling for ripple-only view
|
||||
* Recommended: 20 mV/div, 5–10 ms/div (60–120 ms window)
|
||||
* Trigger: AUTO on Channel 1 so the buffer is always recent
|
||||
* Memory depth: 12M (or whatever fits the timebase)
|
||||
* :RUN the scope so it's continuously acquiring
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import vxi11
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
DEVICE_BASE = "http://192.168.45.8:5000"
|
||||
SN65_EP = f"{DEVICE_BASE}/sn65_registers"
|
||||
RIGOL_IP = "192.168.45.5"
|
||||
DATA_DIR = Path(__file__).parent / "data" / "rail_traces"
|
||||
|
||||
POLL_DT_S = 0.020 # 50 Hz target — coarser than sn65_monitor
|
||||
HTTP_TO_S = 0.2
|
||||
RIGOL_TO_S = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rigol I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
def _read_ieee_block(rigol) -> bytes:
|
||||
"""Read an IEEE 488.2 binary block from the scope: '#'<n><len><data>[\\n]."""
|
||||
head = rigol.read_raw(2)
|
||||
if not head.startswith(b"#"):
|
||||
idx = head.find(b"#")
|
||||
if idx < 0:
|
||||
extra = rigol.read_raw(64)
|
||||
head += extra
|
||||
idx = head.find(b"#")
|
||||
head = head[idx:idx + 2]
|
||||
ndigits = int(head[1:2])
|
||||
length_bytes = rigol.read_raw(ndigits)
|
||||
nbytes = int(length_bytes)
|
||||
data = b""
|
||||
while len(data) < nbytes:
|
||||
chunk = rigol.read_raw(nbytes - len(data))
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
try:
|
||||
rigol.read_raw(1) # trailing newline (may not be present)
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def capture_trace(rigol, label: str) -> tuple[Path, float, float]:
|
||||
"""
|
||||
:STOP → read CH1 → :RUN. Returns (csv_path, vpp_mV, mean_V).
|
||||
"""
|
||||
rigol.write(":STOP")
|
||||
time.sleep(0.06)
|
||||
|
||||
rigol.write(":WAVeform:SOURce CHANnel1")
|
||||
rigol.write(":WAVeform:FORMat BYTE")
|
||||
rigol.write(":WAVeform:MODE NORM")
|
||||
time.sleep(0.02)
|
||||
|
||||
preamble = rigol.ask(":WAVeform:PREamble?").strip().split(",")
|
||||
# format,type,points,count,xinc,xorig,xref,yinc,yorig,yref
|
||||
xinc = float(preamble[4]); xorig = float(preamble[5])
|
||||
yinc = float(preamble[7]); yorig = float(preamble[8])
|
||||
yref = float(preamble[9])
|
||||
|
||||
rigol.write(":WAVeform:DATA?")
|
||||
raw = _read_ieee_block(rigol)
|
||||
codes = np.frombuffer(raw, dtype=np.uint8)
|
||||
volts = (codes.astype(np.float64) - yref - yorig) * yinc
|
||||
t = np.arange(len(volts)) * xinc + xorig
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
||||
csv_path = DATA_DIR / f"{ts}_{label}.csv"
|
||||
np.savetxt(csv_path, np.column_stack([t, volts]),
|
||||
delimiter=",", fmt="%.6e")
|
||||
|
||||
rigol.write(":RUN")
|
||||
vpp_mV = float((volts.max() - volts.min()) * 1000)
|
||||
mean_V = float(volts.mean())
|
||||
return csv_path, vpp_mV, mean_V
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SN65 state extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
def pll_state(data: dict | None):
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
regs = data.get("registers", {})
|
||||
if not isinstance(regs, dict):
|
||||
return None
|
||||
csr_0a = regs.get("csr_0a") or {}
|
||||
return csr_0a.get("pll_lock")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-blocking keys
|
||||
# ---------------------------------------------------------------------------
|
||||
class KeyReader:
|
||||
def __enter__(self):
|
||||
self.fd = sys.stdin.fileno()
|
||||
self.old = termios.tcgetattr(self.fd)
|
||||
tty.setcbreak(self.fd)
|
||||
return self
|
||||
|
||||
def get_key(self) -> str | None:
|
||||
if select.select([sys.stdin], [], [], 0)[0]:
|
||||
return sys.stdin.read(1).lower()
|
||||
return None
|
||||
|
||||
def __exit__(self, *_):
|
||||
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--test", action="store_true",
|
||||
help="Take one immediate trace + exit (verifies Rigol comms)")
|
||||
args = ap.parse_args()
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sess = requests.Session()
|
||||
|
||||
print(f"RAIL WATCH")
|
||||
print(f" sn65 endpoint: {SN65_EP}")
|
||||
print(f" Rigol IP: {RIGOL_IP}")
|
||||
print(f" Output dir: {DATA_DIR.relative_to(DATA_DIR.parent.parent)}")
|
||||
|
||||
try:
|
||||
rigol = vxi11.Instrument(RIGOL_IP)
|
||||
rigol.timeout = RIGOL_TO_S
|
||||
idn = rigol.ask("*IDN?").strip()
|
||||
print(f" Rigol IDN: {idn}")
|
||||
except Exception as e:
|
||||
print(f" *** RIGOL CONNECTION FAILED: {e} ***")
|
||||
sys.exit(1)
|
||||
|
||||
if args.test:
|
||||
print("\n--test: taking one capture now...")
|
||||
try:
|
||||
path, vpp, mean = capture_trace(rigol, "test")
|
||||
print(f" saved {path.name}")
|
||||
print(f" Vpp = {vpp:.1f} mV mean = {mean:.3f} V")
|
||||
except Exception as e:
|
||||
print(f" CAPTURE FAILED: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
def _shutdown(*_):
|
||||
try:
|
||||
rigol.write(":RUN")
|
||||
except Exception:
|
||||
pass
|
||||
print("\nstopped — Rigol restored to RUN")
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
print("\nkeys: g=baseline capture q=quit\n", flush=True)
|
||||
print(f" {'time':<14} {'event':<12} {'file':<40} {'Vpp':>7} {'mean':>7}")
|
||||
print(f" {'-'*14} {'-'*12} {'-'*40} {'-'*7} {'-'*7}")
|
||||
|
||||
last_pll = None
|
||||
unlock_count = 0
|
||||
baseline_count = 0
|
||||
err_count = 0
|
||||
|
||||
with KeyReader() as keys:
|
||||
while True:
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = sess.get(SN65_EP, timeout=HTTP_TO_S)
|
||||
r.raise_for_status()
|
||||
pll = pll_state(r.json())
|
||||
err_count = 0
|
||||
except Exception:
|
||||
pll = None
|
||||
err_count += 1
|
||||
|
||||
# Trigger Rigol on True → False (a real unlock). We ignore the
|
||||
# True → None case (transient I2C read failure) since it isn't
|
||||
# a PLL state change.
|
||||
if last_pll is True and pll is False:
|
||||
unlock_count += 1
|
||||
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
try:
|
||||
path, vpp, mean = capture_trace(
|
||||
rigol, f"unlock_{unlock_count:04d}")
|
||||
print(f" {iso:<14} {'UNLOCK':<12} "
|
||||
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
|
||||
flush=True)
|
||||
except Exception as e:
|
||||
print(f" {iso:<14} UNLOCK CAPTURE FAILED: {e}",
|
||||
flush=True)
|
||||
|
||||
last_pll = pll if pll is not None else last_pll
|
||||
|
||||
# Manual baseline capture
|
||||
key = keys.get_key()
|
||||
if key == "g":
|
||||
baseline_count += 1
|
||||
iso = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
try:
|
||||
path, vpp, mean = capture_trace(
|
||||
rigol, f"baseline_{baseline_count:04d}")
|
||||
print(f" {iso:<14} {'BASELINE':<12} "
|
||||
f"{path.name:<40} {vpp:>5.1f}mV {mean:>5.3f}V",
|
||||
flush=True)
|
||||
except Exception as e:
|
||||
print(f" {iso:<14} BASELINE CAPTURE FAILED: {e}",
|
||||
flush=True)
|
||||
elif key == "q":
|
||||
_shutdown()
|
||||
|
||||
# Pace
|
||||
elapsed = time.time() - t0
|
||||
if elapsed < POLL_DT_S:
|
||||
time.sleep(POLL_DT_S - elapsed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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()
|
||||