813 lines
41 KiB
Python
813 lines
41 KiB
Python
#!/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()
|