#!/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 + banner from the existing template so colours/logo match. The banner has a nested
, so we need the SECOND
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("") body_start = text.find("") # Walk past two tags to clear the nested "who" div + the banner itself pos = text.find('class="banner"') for _ in range(2): pos = text.find("", pos) + len("") body_end_banner = pos return text[:head_end + len("")] + "\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 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('
') html.append(f'

MIPI DSI Flicker — Hardware Exoneration Test

') html.append(f'
Session {session_id}  ·  ' f'Report generated {today_iso}  ·  ' f'{n_total} operator-confirmed flicker observations analysed
') # ── TL;DR ── html.append('
') html.append(f'TL;DR   Across {n_total} operator-confirmed ' f'flicker observations, {n_with_unlock} ({pct_unlock:.0f}%) ' f'produced detectable SN65 PLL unlocks; the remaining ' f'{n_no_change} ({100-pct_unlock:.0f}%) showed no measurable ' f'change 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.
') # ── 1. Method ── html.append('

1. Method

') html.append('

The flicker_burst.py tool was run alongside ' 'video_cycler.py. The operator watched the display while ' 'video was cycled on/off and pressed f the instant any ' 'visible flicker was observed. Each press triggers a synchronised ' 'capture of three independent measurement channels:

') html.append('') html.append('' '') html.append('' '') html.append('' '') html.append('
ChannelInstrumentWhat it captures
SN65 PLL state & error bitsHTTP / I2CContinuous polling at ~50 Hz from f-press until ' 'video_cycler’s next stop event
1V8 supply railRigol DS1202Z-E (CH1)12 s window (10 ms/div × 12), 100 mV/div, ' '−1.8 V offset, DC coupling, 10× probe
MIPI CLK+ & DAT0+Keysight DSO80204B100 segments × 20 µs at 5 GSa/s, LP-edge triggered ' 'at line rate (~48 kHz)
') # ── 2. Results table ── html.append('

2. Per-burst SN65 register summary

') html.append('' '' '' '' '') 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'' f'' f'' f'' f'' f'' f'') html.append('
BurstPressWindow (s)n samplesPLL unlockscsr_0a valuescsr_e5 valuesRail Vpp / mean
{r["burst"]}{r["press_iso"]}{r["duration_s"]:.2f}{r["n_samples"]}{unlock_txt}{e0}{e5}{rail_txt}
') html.append('

Of the eleven observations, two ' f'({pct_unlock:.0f} %) registered a PLL unlock at the ' 'SN65DSI83 bridge. The unlock pulse widths were ' f'{unlock_durations[0]:.1f} ms and ' f'{unlock_durations[1]:.1f} ms — 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.

') # ── 3. Bursts WITH unlocks ── html.append('

3. Bursts with detected PLL unlocks

') html.append('

The following two bursts both showed a brief PLL unlock at ' 'the SN65 (pll_lock went False momentarily, ' 'csr_e5 latched 0x01 for one poll cycle). ' 'The 1V8 rail and MIPI clock traces captured during each burst ' 'show no abnormality outside the SN65 itself.

') for r in results: if r["n_unlocks"] == 0: continue up = r["unlock_pairs"][0] html.append(f'

3.{r["burst"]} Burst {r["burst"]} — press ' f'{r["press_iso"]}, unlock {up["start_iso"]} ' f'({up["duration_ms"]:.1f} ms)

') if f"rail_b{r['burst']}" in plots: html.append(f'') if f"mipi_b{r['burst']}" in plots: html.append('

MIPI overview (20 µs window):

') html.append(f'') if f"mipi_b{r['burst']}_zoom_edge" in plots: html.append('

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:

') html.append(f'') if f"mipi_b{r['burst']}_zoom_hs" in plots: html.append('

Close-up: HS clock oscillation ' '— 50 ns window showing ~10 individual CLK+ cycles ' 'at 216 MHz. Clean square-wave-like alternation ' 'with consistent amplitude:

') html.append(f'') html.append(f'

The rail remained centred on ' f'{r["rail_mean"]:.1f} mV with ' f'{r["rail_vpp"]:.0f} mV 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'{r["mipi_vpp_med"]:.0f} mV).

') # ── 4. Bursts WITHOUT unlocks ── html.append('

4. Bursts with no detectable SN65 state change

') html.append(f'

The following {n_no_change} of {n_total} ' f'operator-confirmed flickers produced no 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.

') html.append('

4.1 1V8 supply rail — representative trace

') if "rail_typical" in plots: html.append(f'') html.append(f'

Across all {n_no_change} no-state-change bursts, the rail mean ' f'was 1.764–1.766 V and Vpp was ' f'120–128 mV — identical to the unlock-bursts ' f'and to clean baselines from earlier sessions.

') html.append('

4.2 MIPI clock and data signals — representative overlay

') html.append('

Wide overview (20 µs window per segment):

') if "mipi_overlay_clk" in plots: html.append(f'') if "mipi_overlay_dat" in plots: html.append(f'') html.append('

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.

') html.append('

4.3 Close-up: LP-11 → HS transition (SoT preamble)

') if "mipi_typical_zoom_edge" in plots: html.append(f'') html.append('

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.

') html.append('

4.4 Close-up: individual HS clock cycles

') if "mipi_typical_zoom_hs" in plots: html.append(f'') html.append('

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.

') html.append('

4.5 Folded eye diagram (CLK+, 20 segments × ~80 cycles)

') if "mipi_typical_eye" in plots: html.append(f'') html.append('

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.

') html.append(f'

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.

') # ── 5. Conclusion ── html.append('

5. Conclusion (current working hypothesis)

') html.append('
') html.append('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.

') html.append('MIPI signal integrity across all ' f'{n_total} operator-confirmed flicker observations is ' 'within nominal envelope and error-free. ' '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 ' 'zero protocol-level errors throughout the test ' '(no SOT-bit, LLP, ECC, LP or CRC error flags raised at ' 'any point in any burst).

') html.append('The 1V8 supply rail shows ' 'no obvious anomalies. 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.

') html.append('On that basis, from the hardware data alone, it is ' 'suspected that the MIPI bus and the 1V8 rail are not the ' 'root cause of the fault. The remaining open ' 'question is what is happening inside 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.

') html.append('Some PLL unlocks were detected during the test ' f'session ({n_with_unlock} of {n_total} flicker ' 'observations). ' 'Not every unlock will have been captured, ' '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.

') html.append('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.') html.append('
') # Rule-out summary table html.append('

5.1 Hypotheses assessed by this test

') html.append('

Based on the measurements taken, the following hypotheses ' 'are not supported by the data; absence of evidence is ' 'not absolute proof of absence, but no signature consistent with ' 'these mechanisms was observed.

') html.append('' '') html.append('' '' f'') html.append('' '' '') html.append('' '' '') html.append('' '') html.append('' '' '') html.append('
HypothesisAssessmentEvidence
Flicker caused by 1V8 supply brownoutNot supportedRail mean voltage consistent across all bursts ' f'(1.764–1.766 V, within 2 %); no DC sag observed ' f'coincident with any flicker
Flicker caused by 1V8 supply ripple spikeNot supportedVpp 120–128 mV consistent across both unlock and ' 'no-unlock bursts — no differentiation
Flicker caused by MIPI clock signal degradationNot supportedCLK+/DAT0+ Vpp distributions consistent across all 11 ' 'bursts; folded-eye overlay shows wide open eye with low jitter; ' 'no outlier segments
Flicker caused by MIPI protocol errors at SN65 ' 'inputNot supportedZero 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)
Flicker caused by MIPI PLL unlockPartial support — explains ~18% of cases2 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)
') # ── 6. Recommended next step ── html.append('

6. Recommended next steps

') html.append('

From a hardware engineering standpoint the data narrows the ' 'remaining candidates for the fault to areas downstream of (or ' 'inside) the SN65DSI83 bridge:

') html.append('') html.append('

The two recommended actions are:

') html.append('
    ') html.append('
  1. 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 ' 'csr_0a/csr_e5) would also give ' 'visibility of any runtime drift in those registers.
  2. ') html.append('
  3. 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.
  4. ') html.append('
') # ── Footnote ── html.append('
Generated from session ' f'{session_id} by make_flicker_report.py ' f'on {today_iso}. Source data: 11 burst captures with ' f'burst_NNNN_*_pll_samples.json, ' f'burst_NNNN_*_rail.csv, and ' f'burst_NNNN_*_mipi_segNNN_clk/dat.csv files in ' f'{session_dir.relative_to(Path.cwd()) if Path.cwd() in session_dir.parents else session_dir}.' '
') html.append('
') 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()