""" csv_preprocessor.py Extracts MIPI HS-TX / LP state metrics from oscilloscope CSV files. File naming convention: YYYYMMDD_HHMMSS_{sig|proto|lp}_{NNNN}_{clk|dat}.csv sig — high-res short window (320 GSa/s, ~20 ns) — rise/fall times Two columns: time_s, vdiff_v (F1/F2 differential, ±250 mV HS swing) proto — lower-res long window (20 GSa/s, ~10 µs) — jitter, frequency, amplitude Two columns: time_s, vdiff_v (F1/F2 differential) lp — LP state capture (~40 GSa/s, ~5 µs) — LP-11/LP-00/HS burst structure Two columns: time_s, voltage_v (Ch1 or Ch3 single-ended CLK+/DAT0+) Vertical range: −0.2 V to 1.4 V so LP-11 (~1.2 V) and LP-00 (~0 V) are visible. Trigger: falling edge at 0.6 V on CLK+ catches LP-11 → LP-01 SoT transition. """ import csv import json import re import numpy as np from dataclasses import dataclass, field from pathlib import Path from typing import Optional # 1.8 V supply rail spec (i.MX 8M Mini internal regulator, ±5 %) V18_NOMINAL_V = 1.800 V18_SPEC_MIN_V = 1.710 # −5 % V18_SPEC_MAX_V = 1.890 # +5 % V18_DROOP_WARN_MV = 50.0 # mV droop depth worth flagging V18_RIPPLE_WARN_MV = 20.0 # mV RMS ripple worth flagging # MIPI D-PHY HS-TX spec limits HS_VDIFF_MIN_MV = 140.0 # |Vdiff| minimum (mV) HS_VDIFF_MAX_MV = 270.0 # |Vdiff| maximum (mV) RISE_FALL_MAX_PS = 500.0 # rise/fall time limit 20%–80% (ps) # Thresholds for "settled" vs "transitioning" TRANSITION_BAND_MV = 50.0 # |Vdiff| < this is considered a transition, not settled # MIPI D-PHY LP state thresholds (single-ended voltage, after probe compensation) LP11_HIGH_V = 0.8 # V — single-ended voltage above this → LP-11 (both pins high ~1.2 V) LP_LOW_V = 0.25 # V — single-ended voltage below this → LP-00 or LP-01 pin low # Note: probe loading can shift LP-low from true 0 V to ~100 mV; 0.25 V clears that offset # The rolling-std gate (HS_OSC_STD_V) prevents HS minima near 0 V being called LP-low. LP11_SPEC_MIN_V = 1.0 # V — LP-11 minimum voltage spec LP11_SPEC_MAX_V = 1.45 # V — LP-11 maximum voltage spec LP_LOW_DUR_MIN_NS = 50.0 # ns — minimum LP-low duration per D-PHY spec (LP-01 + LP-00 combined) HS_OSC_STD_V = 0.045 # V — rolling-std threshold above which a region is classified as HS # Flicker detection thresholds # LP-low plateau below this → SoT sequence too brief for receiver to detect → flicker risk FLICKER_LP_LOW_MAX_NS = 50.0 # ns # CLK lane LP-00 minimum for SN65DSI83 CLK lane lock (TCLK_PREPARE + TCLK_ZERO ≥ 300 ns) CLK_LP_LOW_MIN_NS = 300.0 # HS burst amplitude below this (single-ended p-p / 2, mV) → HS burst absent after LP transition. # On this hardware normal HS = 105–122 mV; confirmed flicker = 14–32 mV (DC / LP-11 recovery). # Captures where LP-01/LP-00 completed normally but the bridge never entered HS mode show # essentially zero amplitude (the burst window is DC LP-11), so lp_low alone cannot detect this. HS_BURST_AMPLITUDE_MIN_MV = 40.0 # mV — below this, no real HS burst is present # Lowered from 50 mV: 48 mV capture (0001) was a false alarm; true flicker (0008) at 34 mV. # Mode A minimum amplitude: LP-11-return edge artifacts produce near-zero amplitude in the # burst window (burst is pure LP-low DC between two LP-11 regions). Require ≥ this to # distinguish a genuine weak-HS attempt from a false rolling-std trigger on LP-11 return. HS_MODE_A_MIN_MV = 10.0 # mV @dataclass class ChannelMetrics: timestamp: str capture_num: int file_type: str # "sig" | "proto" channel: str # "clk" | "dat" sample_rate_gsps: float duration_ns: float n_samples: int # HS-TX differential voltage vdiff_pos_mv: float # mean settled positive level (HS "1") vdiff_neg_mv: float # mean settled negative level (HS "0") vdiff_amplitude_mv: float # (|pos| + |neg|) / 2 — spec: 140–270 mV vcm_mv: float # (pos + neg) / 2 — common-mode offset # Timing (None when there are too few transitions to measure) clock_freq_mhz: Optional[float] = None jitter_pp_ps: Optional[float] = None jitter_rms_ps: Optional[float] = None rise_time_ps: Optional[float] = None fall_time_ps: Optional[float] = None n_transitions: int = 0 # Spec violations spec_violations: int = 0 # settled samples where |Vdiff| < HS_VDIFF_MIN_MV warnings: list = field(default_factory=list) def summary(self) -> str: ok = lambda cond: "✓" if cond else "✗" lines = [ f"Capture {self.capture_num:04d} {self.timestamp} [{self.file_type}/{self.channel}]", f" Vdiff amplitude : {self.vdiff_amplitude_mv:6.1f} mV " f"(spec 140–270 mV) {ok(HS_VDIFF_MIN_MV <= self.vdiff_amplitude_mv <= HS_VDIFF_MAX_MV)}", f" Vdiff pos/neg : +{self.vdiff_pos_mv:.1f} / {self.vdiff_neg_mv:.1f} mV", f" Common mode : {self.vcm_mv:+.1f} mV", ] if self.clock_freq_mhz is not None: lines.append( f" Clock freq : {self.clock_freq_mhz:.2f} MHz DDR " f"({self.n_transitions} transitions)" ) if self.jitter_pp_ps is not None: lines.append( f" Jitter p-p/RMS : {self.jitter_pp_ps:.1f} ps / {self.jitter_rms_ps:.1f} ps" ) if self.rise_time_ps is not None: lines.append( f" Rise time 20-80%: {self.rise_time_ps:.1f} ps " f"{ok(self.rise_time_ps <= RISE_FALL_MAX_PS)}" ) if self.fall_time_ps is not None: lines.append( f" Fall time 20-80%: {self.fall_time_ps:.1f} ps " f"{ok(self.fall_time_ps <= RISE_FALL_MAX_PS)}" ) if self.spec_violations: lines.append(f" Spec violations : {self.spec_violations} samples below {HS_VDIFF_MIN_MV:.0f} mV ✗") for w in self.warnings: lines.append(f" WARNING: {w}") return "\n".join(lines) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _read_csv(path: Path) -> tuple[np.ndarray, np.ndarray]: times, volts = [], [] with open(path) as f: for row in csv.reader(f): if len(row) >= 2: try: times.append(float(row[0])) volts.append(float(row[1])) except ValueError: pass # skip any header row t = np.array(times, dtype=np.float64) v = np.array(volts, dtype=np.float64) if len(t) < 2: raise ValueError(f"Insufficient samples in {path.name} ({len(t)} rows parsed)") return t, v def _zero_crossings(times: np.ndarray, volts: np.ndarray) -> np.ndarray: """Return array of linearly-interpolated zero-crossing times (seconds).""" signs = np.sign(volts) change = np.diff(signs) idx = np.where(change != 0)[0] ct = [] for i in idx: if signs[i] != 0 and signs[i + 1] != 0: frac = -volts[i] / (volts[i + 1] - volts[i]) ct.append(times[i] + frac * (times[i + 1] - times[i])) return np.array(ct) def _rise_fall_times(times: np.ndarray, volts: np.ndarray, v_high: float, v_low: float, window_samples: int = 60) -> tuple[list, list]: """ Measure 20%–80% rise and fall times around each zero crossing. Returns (rise_times_ps, fall_times_ps). """ v20 = v_low + 0.20 * (v_high - v_low) v80 = v_low + 0.80 * (v_high - v_low) signs = np.sign(volts) trans_idx = np.where(np.diff(signs) != 0)[0] rise_ps, fall_ps = [], [] for idx in trans_idx: s = max(0, idx - window_samples // 2) e = min(len(times), idx + window_samples // 2) tw = times[s:e] vw = volts[s:e] if len(vw) < 4: continue if volts[min(idx + 1, len(volts) - 1)] > volts[idx]: # rising edge # find where vw first crosses v20 (ascending) then v80 i20 = np.searchsorted(vw, v20) i80 = np.searchsorted(vw, v80) if 0 < i20 < len(tw) - 1 and 0 < i80 < len(tw) - 1 and i80 > i20: # interpolate each threshold t20 = np.interp(v20, vw[i20 - 1:i20 + 1], tw[i20 - 1:i20 + 1]) t80 = np.interp(v80, vw[i80 - 1:i80 + 1], tw[i80 - 1:i80 + 1]) rise_ps.append((t80 - t20) * 1e12) else: # falling edge # descending: reverse the window so searchsorted still works vw_r = vw[::-1] tw_r = tw[::-1] i80 = np.searchsorted(vw_r, v80) i20 = np.searchsorted(vw_r, v20) if 0 < i80 < len(tw_r) - 1 and 0 < i20 < len(tw_r) - 1 and i20 > i80: t80 = np.interp(v80, vw_r[i80 - 1:i80 + 1], tw_r[i80 - 1:i80 + 1]) t20 = np.interp(v20, vw_r[i20 - 1:i20 + 1], tw_r[i20 - 1:i20 + 1]) fall_ps.append((t20 - t80) * 1e12) return rise_ps, fall_ps # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def analyze_file(path: Path) -> ChannelMetrics: """ Analyse one oscilloscope CSV file and return a ChannelMetrics instance. """ m = re.match(r"(\d{8}_\d{6})_(sig|proto|lp)_(\d+)_(clk|dat)\.csv", path.name, re.IGNORECASE) if not m: raise ValueError(f"Filename does not match expected pattern: {path.name}") timestamp, file_type, cap_str, channel = m.groups() if file_type == "lp": raise ValueError("Use analyze_lp_file() for lp-type files (single-ended)") capture_num = int(cap_str) times, volts = _read_csv(path) dt = float(np.diff(times).mean()) sample_rate = 1.0 / dt duration_ns = (float(times[-1]) - float(times[0])) * 1e9 # --- Voltage levels --- v_thresh = TRANSITION_BAND_MV / 1000.0 pos_mask = volts > v_thresh neg_mask = volts < -v_thresh vdiff_pos = float(volts[pos_mask].mean()) * 1000.0 if pos_mask.any() else 0.0 vdiff_neg = float(volts[neg_mask].mean()) * 1000.0 if neg_mask.any() else 0.0 # Classify signal coverage: # no_signal — neither polarity detected (LP state or idle) # one_sided — only one polarity in capture window (short sig window, uniform data) no_signal = (not pos_mask.any()) and (not neg_mask.any()) one_sided = (not no_signal) and ((not pos_mask.any()) or (not neg_mask.any())) if no_signal: amplitude = 0.0 elif one_sided: amplitude = max(abs(vdiff_pos), abs(vdiff_neg)) else: amplitude = (abs(vdiff_pos) + abs(vdiff_neg)) / 2.0 vcm = (vdiff_pos + vdiff_neg) / 2.0 # --- Zero crossings → frequency + jitter (CLK only) --- ct = _zero_crossings(times, volts) n_transitions = len(ct) clock_freq_mhz = jitter_pp_ps = jitter_rms_ps = None # Jitter / frequency are only meaningful on the CLK lane. # On DAT the bit pattern varies, so half-periods are not uniform by design. # Require at least 20 transitions (10 full cycles) for reliable jitter. # Sig files (~8 transitions) are too short; proto files (~4000) are fine. if channel == "clk" and n_transitions >= 20: half_periods = np.diff(ct) * 1e12 # ps med = float(np.median(half_periods)) sd = float(half_periods.std()) # Remove outliers beyond 3σ (spurious glitches) hp = half_periods[np.abs(half_periods - med) < 3.0 * sd] if sd > 0 else half_periods if len(hp) >= 20: clock_freq_mhz = round(1.0 / (float(np.median(hp)) * 2e-12) / 1e6, 2) jitter_pp_ps = round(float(hp.max() - hp.min()), 1) jitter_rms_ps = round(float(hp.std()), 1) # --- Rise / fall times --- v_high = vdiff_pos / 1000.0 v_low = vdiff_neg / 1000.0 rise_list, fall_list = _rise_fall_times(times, volts, v_high, v_low) rise_time_ps = round(float(np.median(rise_list)), 1) if rise_list else None fall_time_ps = round(float(np.median(fall_list)), 1) if fall_list else None # --- Spec violations --- # Only check samples that are well away from any zero crossing (bit-centres). # Transitions naturally pass through sub-140 mV, so counting them as violations # would be misleading. We mask out a ±guard window around each crossing. guard_s = float(np.median(np.diff(ct))) * 0.35 if n_transitions >= 4 else dt * 10 in_guard = np.zeros(len(times), dtype=bool) for t_cross in ct: lo = np.searchsorted(times, t_cross - guard_s) hi = np.searchsorted(times, t_cross + guard_s) in_guard[lo:hi] = True settled = (~in_guard) & (np.abs(volts) > v_thresh) # "Transient" violations: settled samples that dip noticeably below the # measured settled amplitude (threshold = 85 % of the smaller settled level). # This catches genuine dips without flagging cases where the settled level # itself is just marginally below spec (which is reported as a WARNING instead). floor_v = 0.85 * min(abs(vdiff_pos / 1000.0), abs(vdiff_neg / 1000.0)) if ( vdiff_pos and vdiff_neg) else HS_VDIFF_MIN_MV / 1000.0 spec_violations = int(np.sum(settled & (np.abs(volts) < floor_v))) # --- Warnings --- warnings = [] if no_signal: warnings.append("No HS signal detected — line may be in LP state or idle") elif one_sided: polarity = "positive" if pos_mask.any() else "negative" warnings.append( f"Only {polarity} swings in capture window — amplitude may be underestimated" ) if not no_signal and amplitude < HS_VDIFF_MIN_MV: warnings.append(f"Vdiff {amplitude:.0f} mV below spec min {HS_VDIFF_MIN_MV:.0f} mV") if amplitude > HS_VDIFF_MAX_MV: warnings.append(f"Vdiff {amplitude:.0f} mV above spec max {HS_VDIFF_MAX_MV:.0f} mV") if rise_time_ps is not None and rise_time_ps > RISE_FALL_MAX_PS: warnings.append(f"Rise time {rise_time_ps:.0f} ps exceeds {RISE_FALL_MAX_PS:.0f} ps") if fall_time_ps is not None and fall_time_ps > RISE_FALL_MAX_PS: warnings.append(f"Fall time {fall_time_ps:.0f} ps exceeds {RISE_FALL_MAX_PS:.0f} ps") if spec_violations > 0: warnings.append(f"{spec_violations} settled samples below {HS_VDIFF_MIN_MV:.0f} mV") return ChannelMetrics( timestamp = timestamp, capture_num = capture_num, file_type = file_type, channel = channel, sample_rate_gsps = round(sample_rate / 1e9, 1), duration_ns = round(duration_ns, 2), n_samples = len(times), vdiff_pos_mv = round(vdiff_pos, 1), vdiff_neg_mv = round(vdiff_neg, 1), vdiff_amplitude_mv = round(amplitude, 1), vcm_mv = round(vcm, 1), clock_freq_mhz = clock_freq_mhz, jitter_pp_ps = jitter_pp_ps, jitter_rms_ps = jitter_rms_ps, rise_time_ps = rise_time_ps, fall_time_ps = fall_time_ps, n_transitions = n_transitions, spec_violations = spec_violations, warnings = warnings, ) @dataclass class V1V8Metrics: timestamp: str capture_num: int sample_rate_mhz: float duration_us: float n_samples: int mean_v: float # mean supply voltage min_v: float # minimum (worst-case droop) max_v: float # maximum droop_mv: float # mean − min (droop depth) ripple_mv_rms: float # AC ripple (std dev of voltage) spec_pass: bool # mean within ±5 % of 1.8 V droop_pass: bool # minimum above V18_SPEC_MIN_V warnings: list = field(default_factory=list) def summary(self) -> str: ok = lambda c: "✓" if c else "✗" lines = [ f"Capture {self.capture_num:04d} {self.timestamp} [pwr/1v8]", f" Mean voltage : {self.mean_v:.4f} V " f"(spec {V18_SPEC_MIN_V:.2f}–{V18_SPEC_MAX_V:.2f} V) {ok(self.spec_pass)}", f" Min voltage : {self.min_v:.4f} V {ok(self.droop_pass)}", f" Droop depth : {self.droop_mv:.1f} mV", f" Ripple RMS : {self.ripple_mv_rms:.2f} mV", ] for w in self.warnings: lines.append(f" WARNING: {w}") return "\n".join(lines) def analyze_1v8_file(path: Path) -> "V1V8Metrics": """Analyse a 1.8 V supply rail CSV captured by the Rigol DS1202Z-E.""" m = re.match(r"(\d{8}_\d{6})_pwr_(\d+)_1v8\.csv", path.name, re.IGNORECASE) if not m: raise ValueError(f"Filename does not match 1v8 pattern: {path.name}") timestamp, cap_str = m.groups() capture_num = int(cap_str) times, volts = _read_csv(path) dt = float(np.diff(times).mean()) sample_rate = 1.0 / dt duration_us = (float(times[-1]) - float(times[0])) * 1e6 mean_v = float(volts.mean()) min_v = float(volts.min()) max_v = float(volts.max()) droop_mv = (mean_v - min_v) * 1000.0 ripple_mv_rms = float(volts.std()) * 1000.0 spec_pass = V18_SPEC_MIN_V <= mean_v <= V18_SPEC_MAX_V droop_pass = min_v >= V18_SPEC_MIN_V warnings = [] if not spec_pass: warnings.append( f"Mean supply {mean_v:.4f} V outside spec " f"({V18_SPEC_MIN_V:.2f}–{V18_SPEC_MAX_V:.2f} V)" ) if not droop_pass: warnings.append( f"Supply droops to {min_v:.4f} V — below {V18_SPEC_MIN_V:.2f} V spec min" ) if droop_mv > V18_DROOP_WARN_MV: warnings.append( f"Droop depth {droop_mv:.1f} mV — possible insufficient decoupling near MIPI PHY" ) if ripple_mv_rms > V18_RIPPLE_WARN_MV: warnings.append(f"Ripple {ripple_mv_rms:.1f} mV RMS is elevated") return V1V8Metrics( timestamp = timestamp, capture_num = capture_num, sample_rate_mhz = round(sample_rate / 1e6, 1), duration_us = round(duration_us, 2), n_samples = len(times), mean_v = round(mean_v, 4), min_v = round(min_v, 4), max_v = round(max_v, 4), droop_mv = round(droop_mv, 1), ripple_mv_rms = round(ripple_mv_rms, 2), spec_pass = spec_pass, droop_pass = droop_pass, warnings = warnings, ) # --------------------------------------------------------------------------- # DSIM PHY timing register decoder (D-PHY v1.1 Table 14 @ 432 Mbit/s, 54 MHz byte clock) # --------------------------------------------------------------------------- # Byte-clock period used to convert register fields (in byte-clock units) to nanoseconds. # 54 MHz byte clock → 18.518 ns per byte clock. _DSIM_BYTE_PERIOD_NS = 18.518 # Per-field decode table. Key = lowest 2 hex digits of register address. # Each entry: (field_name, bit_shift, byte_mask, spec) # spec = ("min", ns) — field_ns must be ≥ ns # ("range", lo, hi) — field_ns must be lo ≤ x ≤ hi # None — not individually checked (part of a combined check only) _DSIM_PHY_FIELDS: dict[str, list] = { "b4": [ # PHYTIMING 0x32e100b4 ("TLPX", 8, 0xFF, ("min", 50.0)), ("THS_EXIT", 0, 0xFF, ("min", 100.0)), ], "b8": [ # PHYTIMING1 0x32e100b8 ("TCLK_PREPARE", 24, 0xFF, ("range", 38.0, 95.0)), ("TCLK_ZERO", 16, 0xFF, None), # combined with TCLK_PREPARE ≥ 300 ns ("TCLK_POST", 8, 0xFF, ("min", 180.4)), ("TCLK_TRAIL", 0, 0xFF, ("min", 60.0)), ], "bc": [ # PHYTIMING2 0x32e100bc # Field order verified against kernel logs (samsung_dsim_set_phy_ctrl): # [23:16]=THS_PREPARE, [15:8]=THS_ZERO, [7:0]=THS_TRAIL ("THS_PREPARE", 16, 0xFF, ("range", 49.3, 98.9)), ("THS_ZERO", 8, 0xFF, None), # combined with THS_PREPARE ≥ 168.2 ns ("THS_TRAIL", 0, 0xFF, ("min", 69.3)), ], } # Combined (sum) checks applied after individual field decoding. # (field_a, field_b, min_ns, label) _DSIM_COMBINED_CHECKS = [ ("TCLK_PREPARE", "TCLK_ZERO", 300.0, "TCLK_PREPARE+TCLK_ZERO"), ("THS_PREPARE", "THS_ZERO", 168.2, "THS_PREPARE+THS_ZERO"), ] def _decode_dsim_registers(registers: list) -> list[str]: """ Decode DSIM PHY timing registers and return a list of annotated strings, one per field, with D-PHY v1.1 spec compliance check results. """ ok = lambda c: "✓" if c else "✗ VIOLATION" lines = [] field_ns: dict[str, float] = {} for reg in registers: addr_str = reg.get("address", "").lower().lstrip("0x") val_str = reg.get("value", "0x0").lower() suffix = addr_str[-2:] if len(addr_str) >= 2 else "" fields = _DSIM_PHY_FIELDS.get(suffix) if fields is None: continue # register not in our decode table try: val = int(val_str, 16) except ValueError: lines.append(f" {reg.get('address')} : {reg.get('value')} (parse error)") continue reg_name = reg.get("name") or f"0x{addr_str}" lines.append(f" {reg.get('address')} ({reg_name}) = {val_str}") for (fname, shift, mask, spec) in fields: raw = (val >> shift) & mask ns = raw * _DSIM_BYTE_PERIOD_NS field_ns[fname] = ns if spec is None: # shown in combined check only lines.append(f" {fname:<16s} = {raw:3d} bc → {ns:6.1f} ns (combined check below)") elif spec[0] == "min": pass_check = ns >= spec[1] lines.append( f" {fname:<16s} = {raw:3d} bc → {ns:6.1f} ns " f"(spec ≥ {spec[1]:.1f} ns) {ok(pass_check)}" ) elif spec[0] == "range": pass_check = spec[1] <= ns <= spec[2] lines.append( f" {fname:<16s} = {raw:3d} bc → {ns:6.1f} ns " f"(spec {spec[1]:.1f}–{spec[2]:.1f} ns) {ok(pass_check)}" ) # Combined sum checks for (fa, fb, min_ns, label) in _DSIM_COMBINED_CHECKS: if fa in field_ns and fb in field_ns: total = field_ns[fa] + field_ns[fb] pass_check = total >= min_ns lines.append( f" {label:<28s} = {total:6.1f} ns (spec ≥ {min_ns:.1f} ns) {ok(pass_check)}" ) return lines @dataclass class RegDump: """DSI controller register snapshot read from device via memtool.""" timestamp: str capture_num: int commands: list # list of memtool command strings that were run registers: list # [{"address": "0x...", "value": "0x...", "name": "..."}, ...] errors: list # any device-side errors def summary(self) -> str: lines = [f"Capture {self.capture_num:04d} {self.timestamp} [reg/dsi_phy]"] if self.errors: for err in self.errors: lines.append(f" WARNING: {err}") if not self.registers: lines.append(" No registers captured") return "\n".join(lines) lines.append(f" Commands : {'; '.join(self.commands)}") decoded = _decode_dsim_registers(self.registers) if decoded: lines.extend(decoded) else: # Fallback: raw hex dump if no addresses matched decode table for r in self.registers: name = f" ({r['name']})" if r.get("name") else "" lines.append(f" {r['address']} : {r['value']}{name}") return "\n".join(lines) def analyze_reg_file(path: Path) -> "RegDump": """Read a register JSON file saved by mipi_test._fetch_registers().""" m = re.match(r"(\d{8}_\d{6})_reg_(\d+)\.json", path.name, re.IGNORECASE) if not m: raise ValueError(f"Filename does not match register pattern: {path.name}") timestamp, cap_str = m.groups() data = json.loads(path.read_text()) return RegDump( timestamp = timestamp, capture_num = int(cap_str), commands = data.get("commands", []), registers = data.get("registers", []), errors = data.get("errors") or [], ) # --------------------------------------------------------------------------- # SN65DSI83 IRQ pin analysis (Rigol CH2 — CMOS output, active HIGH) # --------------------------------------------------------------------------- # IRQ is a CMOS output (Table 5-1). Default state (IRQ_EN=0): high-impedance → reads ~0 V. # When IRQ_EN=1 (CSR 0xE0.0): driven LOW (~0 V) when no error, HIGH (≥1.25 V) on error. # No pull-up required. 0 V is normal. Assertion requires IRQ_EN=1 + error bits in CSR 0xE1. INT_ASSERTED_HIGH_V = 1.0 # V — IRQ considered asserted (error) above this @dataclass class INTMetrics: timestamp: str capture_num: int sample_rate_mhz: float duration_us: float n_samples: int mean_v: float min_v: float max_v: float int_asserted: bool # True if IRQ went above INT_ASSERTED_HIGH_V asserted_duration_us: Optional[float] # total assertion time, or None if not asserted warnings: list = field(default_factory=list) def summary(self) -> str: ok = lambda c: "✓" if c else "✗" lines = [ f"Capture {self.capture_num:04d} {self.timestamp} [int/irq]", f" IRQ mean/min/max : {self.mean_v:.3f} V / {self.min_v:.3f} V / {self.max_v:.3f} V", ] if self.int_asserted: dur_str = (f" ({self.asserted_duration_us:.2f} µs)" if self.asserted_duration_us else "") lines.append( f" IRQ status : *** ASSERTED HIGH — bridge flagged error{dur_str} *** ✗" ) else: lines.append(f" IRQ status : not asserted (no bridge error) ✓") for w in self.warnings: lines.append(f" WARNING: {w}") return "\n".join(lines) def analyze_int_file(path: Path) -> "INTMetrics": """Analyse a Rigol CH2 IRQ pin CSV file.""" m = re.match(r"(\d{8}_\d{6})_int_(\d+)\.csv", path.name, re.IGNORECASE) if not m: raise ValueError(f"Filename does not match int pattern: {path.name}") timestamp, cap_str = m.groups() capture_num = int(cap_str) times, volts = _read_csv(path) dt = float(np.diff(times).mean()) sample_rate = 1.0 / dt duration_us = (float(times[-1]) - float(times[0])) * 1e6 mean_v = float(volts.mean()) min_v = float(volts.min()) max_v = float(volts.max()) asserted_mask = volts > INT_ASSERTED_HIGH_V int_asserted = bool(asserted_mask.any()) asserted_duration_us = None if int_asserted: asserted_duration_us = round(float(asserted_mask.sum()) * dt * 1e6, 3) warnings = [] if max_v < 0.1 and mean_v < 0.1: warnings.append( f"IRQ pin reads ~0 V throughout — likely high-impedance (IRQ_EN=0, default). " f"Set CSR 0xE0.0=1 and enable error bits in CSR 0xE1 to activate IRQ output." ) return INTMetrics( timestamp = timestamp, capture_num = capture_num, sample_rate_mhz = round(sample_rate / 1e6, 1), duration_us = round(duration_us, 2), n_samples = len(times), mean_v = round(mean_v, 3), min_v = round(min_v, 3), max_v = round(max_v, 3), int_asserted = int_asserted, asserted_duration_us = asserted_duration_us, warnings = warnings, ) def group_captures(data_dir: Path) -> dict[tuple[str, int], dict[str, Path]]: """ Scan data_dir and group CSV files by (timestamp, capture_number). Returns dict mapping (timestamp, num) → {file_type_channel: Path}. Example key: ("20260408_111448", 1) Example value: {"sig_clk": Path(...), "sig_dat": ..., "proto_clk": ..., "proto_dat": ...} """ csv_pattern = re.compile( r"(\d{8}_\d{6})_(sig|proto|lp|pwr)_(\d+)_(clk|dat|1v8)\.csv", re.IGNORECASE ) reg_pattern = re.compile( r"(\d{8}_\d{6})_reg_(\d+)\.json", re.IGNORECASE ) groups: dict[tuple[str, int], dict[str, Path]] = {} for f in sorted(data_dir.glob("*.csv")): m = csv_pattern.match(f.name) if not m: continue ts, ftype, cap_str, ch = m.groups() key = (ts, int(cap_str)) groups.setdefault(key, {})[f"{ftype}_{ch}"] = f for f in sorted(data_dir.glob("*.json")): m = reg_pattern.match(f.name) if not m: continue ts, cap_str = m.groups() key = (ts, int(cap_str)) groups.setdefault(key, {})["reg"] = f return groups # --------------------------------------------------------------------------- # LP state analysis (lp_clk / lp_dat — single-ended Ch1 / Ch3 captures) # --------------------------------------------------------------------------- @dataclass class LPMetrics: timestamp: str capture_num: int channel: str # "clk" | "dat" sample_rate_gsps: float duration_us: float n_samples: int # LP-11 (both pins high ~1.2 V) lp11_voltage_v: Optional[float] # mean level in LP-11 region (spec 1.0–1.45 V) lp11_duration_us: Optional[float] # total LP-11 time in capture (pre-trigger) # LP exit: gap between LP-11 falling edge and HS oscillation onset lp11_to_hs_ns: Optional[float] # total LP exit time LP-11→HS (includes LP-01+LP-00) lp_low_duration_ns: Optional[float] # LP-low plateau duration if a clear plateau was seen # HS bursts detected within the window n_hs_bursts: int hs_burst_dur_ns: Optional[float] # mean HS burst duration hs_amplitude_mv: Optional[float] # peak-to-peak single-ended HS swing (mV) lp_transition_valid: bool # LP-11 → LP-low → HS sequence present # CLK lane startup check (only set when CLK LP-11 is captured — i.e. startup was caught) # None = CLK was in continuous HS when triggered (startup not visible in this capture) # True = CLK LP-00 duration ≥ 300 ns (SN65DSI83 CLK lock spec met) # False = CLK LP-00 too short → bridge may fail to lock CLK lane clk_lp_startup_ok: Optional[bool] = None # Flicker detection # A capture is flagged when the LP-low plateau is absent or shorter than # FLICKER_LP_LOW_MAX_NS. Normal captures show ~340 ns; flicker shows 0–50 ns. flicker_suspect: bool = False warnings: list = field(default_factory=list) def summary(self) -> str: ok = lambda c: "✓" if c else "✗" lines = [ f"Capture {self.capture_num:04d} {self.timestamp} [lp/{self.channel}]", ] if self.lp11_voltage_v is not None: in_spec = LP11_SPEC_MIN_V <= self.lp11_voltage_v <= LP11_SPEC_MAX_V lines.append( f" LP-11 voltage : {self.lp11_voltage_v:.3f} V " f"(spec {LP11_SPEC_MIN_V:.1f}–{LP11_SPEC_MAX_V:.2f} V) {ok(in_spec)}" ) if self.lp11_duration_us is not None: lines.append(f" LP-11 duration : {self.lp11_duration_us:.2f} µs") if self.lp11_to_hs_ns is not None: ok_exit = self.lp11_to_hs_ns >= LP_LOW_DUR_MIN_NS lines.append( f" LP exit → HS : {self.lp11_to_hs_ns:.0f} ns " f"(spec ≥{LP_LOW_DUR_MIN_NS:.0f} ns) {ok(ok_exit)}" ) if self.lp_low_duration_ns is not None: if self.channel == "clk": ok_clk = self.lp_low_duration_ns >= CLK_LP_LOW_MIN_NS lines.append( f" LP-00 (CLK) : {self.lp_low_duration_ns:.0f} ns " f"(spec ≥{CLK_LP_LOW_MIN_NS:.0f} ns for bridge CLK lock) " f"{'✓' if ok_clk else '✗'}" ) else: lines.append(f" LP-low plateau : {self.lp_low_duration_ns:.0f} ns") if self.clk_lp_startup_ok is not None: lines.append( f" CLK startup : {'ok ✓' if self.clk_lp_startup_ok else '*** SHORT — bridge may not lock CLK ✗'}" ) lines.append( f" LP→HS sequence : {'valid ✓' if self.lp_transition_valid else 'NOT DETECTED ✗'}" ) if self.n_hs_bursts: lines.append(f" HS bursts : {self.n_hs_bursts}" + (f" avg {self.hs_burst_dur_ns:.0f} ns" if self.hs_burst_dur_ns else "")) if self.hs_amplitude_mv is not None: lines.append(f" HS amplitude : {self.hs_amplitude_mv:.0f} mV (single-ended p-p/2)") if self.flicker_suspect: if not self.lp_transition_valid and not self.lp11_voltage_v: lines.append( f" *** FLICKER SUSPECT: MIPI link silent — no LP-11, LP-low, or HS detected ***" ) elif (self.hs_amplitude_mv is not None and self.hs_amplitude_mv < HS_BURST_AMPLITUDE_MIN_MV and self.lp11_to_hs_ns is not None and self.lp11_to_hs_ns >= LP_LOW_DUR_MIN_NS): lines.append( f" *** FLICKER SUSPECT: HS burst absent " f"(amplitude {self.hs_amplitude_mv:.0f} mV < {HS_BURST_AMPLITUDE_MIN_MV:.0f} mV, " f"lp11_to_hs={self.lp11_to_hs_ns:.0f} ns) ***" ) else: lines.append( f" *** FLICKER SUSPECT: LP-low plateau absent or < {FLICKER_LP_LOW_MAX_NS:.0f} ns ***" ) for w in self.warnings: lines.append(f" WARNING: {w}") return "\n".join(lines) def _rolling_std(arr: np.ndarray, window: int) -> np.ndarray: """Compute rolling standard deviation using stride_tricks (O(n) memory, fast).""" from numpy.lib.stride_tricks import sliding_window_view n = len(arr) if n <= window: return np.full(n, arr.std()) windowed = sliding_window_view(arr, window) stds = windowed.std(axis=1) # Pad edges to maintain original length pad_l = window // 2 pad_r = n - len(stds) - pad_l return np.concatenate([np.full(pad_l, stds[0]), stds, np.full(pad_r, stds[-1])]) def _find_contiguous_regions(mask: np.ndarray, min_samples: int = 5): """Return list of (start_idx, end_idx) for True runs ≥ min_samples long.""" padded = np.concatenate([[False], mask, [False]]) diff = np.diff(padded.astype(np.int8)) starts = np.where(diff == 1)[0] ends = np.where(diff == -1)[0] return [(s, e) for s, e in zip(starts, ends) if (e - s) >= min_samples] def analyze_lp_file(path: Path) -> "LPMetrics": """ Analyse a single-ended LP capture CSV (Ch1 or Ch3) and return LPMetrics. State classification per sample: LP-11 : voltage > LP11_HIGH_V (~1.2 V, both pins high) LP-low : voltage < LP_LOW_V (~0 V, pin driven low — LP-01 or LP-00) HS : voltage in mid-range with high oscillation (rolling std > HS_OSC_STD_V) trans : everything else (transitions between states) """ m = re.match(r"(\d{8}_\d{6})_lp_(\d+)_(clk|dat)\.csv", path.name, re.IGNORECASE) if not m: raise ValueError(f"Filename does not match lp pattern: {path.name}") timestamp, cap_str, channel = m.groups() capture_num = int(cap_str) times, volts = _read_csv(path) dt = float(np.diff(times).mean()) sample_rate = 1.0 / dt duration_us = (float(times[-1]) - float(times[0])) * 1e6 # ── LP-11 detection ─────────────────────────────────────────────────── # LP-11 is reliable: voltage is clearly above LP11_HIGH_V (0.8 V). lp11_mask = volts > LP11_HIGH_V lp11_regions = _find_contiguous_regions(lp11_mask, min_samples=10) lp11_voltage_v = None lp11_duration_us = None if lp11_regions: lp11_voltage_v = round(float(np.concatenate( [volts[s:e] for s, e in lp11_regions]).mean()), 3) lp11_duration_us = round( sum((times[min(e, len(times) - 1)] - times[s]) for s, e in lp11_regions) * 1e6, 3) # ── HS burst detection ──────────────────────────────────────────────── # On DAT0+ with a uniform-colour display, HS data can look DC (no bit # transitions), making oscillation-based HS detection unreliable. # Instead: every non-LP-11 gap between LP-11 regions is treated as an # HS burst. The first gap starts at the end of the first LP-11 region; # subsequent gaps are between consecutive LP-11 regions. lp11_to_hs_ns = None lp_low_duration_ns = None lp_transition_valid = False n_hs_bursts = 0 hs_burst_dur_ns = None hs_amplitude_mv = None if len(lp11_regions) >= 1: # Measure LP-11 → HS exit gap (LP-01 + LP-00 combined) using a rolling # std: the brief exit transition is the first period of measurable # oscillation (rolling std > threshold) after LP-11 ends. window = max(10, int(1e-9 / dt)) rstd = _rolling_std(volts, window) hs_bursts = [] for i, (lp11_s, lp11_e) in enumerate(lp11_regions): # Burst ends at start of next LP-11, or at window end burst_end = lp11_regions[i + 1][0] if i + 1 < len(lp11_regions) else len(times) - 1 lp11_e_idx = min(lp11_e, len(times) - 1) # guard: region end can == len(times) burst_dur_ns = round((times[burst_end] - times[lp11_e_idx]) * 1e9, 1) hs_bursts.append((lp11_e_idx, burst_end, burst_dur_ns)) if hs_bursts: n_hs_bursts = len(hs_bursts) hs_burst_dur_ns = round(float(np.mean([d for _, _, d in hs_bursts])), 1) lp_transition_valid = True # LP exit gap: find first rolling-std > threshold after LP-11 ends s_end = lp11_regions[0][1] lookahead = min(s_end + int(500e-9 / dt), len(times) - 1) high_std_idx = np.where(rstd[s_end:lookahead] >= HS_OSC_STD_V)[0] if len(high_std_idx): lp11_to_hs_ns = round((times[s_end + high_std_idx[0]] - times[s_end]) * 1e9, 1) # LP-low plateau: look for a contiguous region in the exit window # where voltage < LP_LOW_V and std is low (true LP-01/LP-00 plateau) lp_low_mask = (volts < LP_LOW_V) & (rstd < HS_OSC_STD_V) # Time-based minimum: reject glitches shorter than 5 ns. # At ~40 GSa/s (25 ps/sample) the old min_samples=5 admitted 125 ps noise spikes. _min_lp_low = max(5, int(5e-9 / dt)) lp_low_regions = _find_contiguous_regions(lp_low_mask, min_samples=_min_lp_low) exit_window = int(1e-6 / dt) for lplow_s, lplow_e in lp_low_regions: if s_end <= lplow_s <= s_end + exit_window: lp_low_duration_ns = round( (times[min(lplow_e, len(times) - 1)] - times[lplow_s]) * 1e9, 1) break # HS single-ended amplitude from the first burst (where data may vary) if hs_bursts: s, e, _ = hs_bursts[0] burst_volts = volts[s:e] if len(burst_volts) >= 2: hs_amplitude_mv = round( (float(np.percentile(burst_volts, 95)) - float(np.percentile(burst_volts, 5))) / 2 * 1000, 1 ) # ── Warnings ───────────────────────────────────────────────────────── warnings = [] continuous_hs_clk = (not lp11_regions) and (channel == "clk") and (float(volts.max()) < LP11_HIGH_V) if continuous_hs_clk: warnings.append("CLK lane is in continuous HS mode — LP states not expected on CLK") elif not lp11_regions: warnings.append("No LP-11 state detected in capture window") elif lp11_voltage_v is not None: if lp11_voltage_v < LP11_SPEC_MIN_V: warnings.append(f"LP-11 voltage {lp11_voltage_v:.3f} V below spec min {LP11_SPEC_MIN_V} V") if lp11_voltage_v > LP11_SPEC_MAX_V: warnings.append(f"LP-11 voltage {lp11_voltage_v:.3f} V above spec max {LP11_SPEC_MAX_V} V") if lp11_to_hs_ns is not None and lp11_to_hs_ns < LP_LOW_DUR_MIN_NS: warnings.append( f"LP exit duration {lp11_to_hs_ns:.0f} ns below spec min {LP_LOW_DUR_MIN_NS:.0f} ns " f"— LP-01/LP-00 states may be absent or too brief" ) if not continuous_hs_clk: if not lp_transition_valid: warnings.append("LP-11 → LP-low → HS transition sequence not detected") if n_hs_bursts == 0: warnings.append("No HS bursts detected after LP transition") # CLK lane startup check — only relevant when CLK LP-11 was captured (startup visible) clk_lp_startup_ok: Optional[bool] = None if channel == "clk" and lp11_regions and lp_low_duration_ns is not None: clk_lp_startup_ok = lp_low_duration_ns >= CLK_LP_LOW_MIN_NS if not clk_lp_startup_ok: warnings.append( f"CLK LP-00 {lp_low_duration_ns:.0f} ns < {CLK_LP_LOW_MIN_NS:.0f} ns " f"(TCLK_PREPARE+TCLK_ZERO minimum) — SN65DSI83 may fail to lock CLK lane" ) # Flicker suspect: four confirmed failure modes on this hardware: # # A) Normal LP-low (~342–380 ns) → bridge misses SoT → returns to LP-11 # Signature: lp11_to_hs fires at real LP-low end (~347 ns), hs_amplitude ≈ 15–30 mV. # Guard: lp11_to_hs >= LP_LOW_DUR_MIN_NS prevents DC-content false positives # where the ~3 ns noise spike fires the gate but HS IS present. # # A2) LP-11 present, HS attempt made but amplitude too weak for rolling-std to fire # Signature: lp11_to_hs is None (rolling-std < HS_OSC_STD_V throughout 500 ns # lookahead), hs_amplitude < 50 mV, LP-11 returns ~500 ns later. # Root cause: marginal VDD_DSI (LP-11 sags to ~1.0 V vs 1.2 V nominal), reducing # HS drive strength below the detection threshold. # Confirmed: capture 0010 (lp11_to_hs=None, amp≈32 mV, LP-11 returned at +513 ns). # # B) Short LP-low (50–200 ns, vs nominal ~342–380 ns) → marginal SoT timing # → HS burst starts but is weak, hs_amplitude ≈ 40–60 mV (vs normal 100–122 mV). # Signature: lp_low anomalously short, lp11_to_hs fires at noise spike (~3 ns). # The lp11_to_hs guard cannot be used here, so LP-low duration gates the check. # Confirmed example: capture 0120 (lp_low=108 ns, lp11_to_hs=1.7 ns, amp=49 mV). # # C) No LP-11 detected at all → MIPI link silent or stuck # Signature: no LP-11 region found, lp_transition_valid=False, no LP or HS seen. # This is the most severe failure: the bridge or DSI IP has stopped outputting. # Confirmed: follow-up of capture 13 (no LP-11, no HS) while display was flickering. # Guard: only flag DAT lane (not CLK which is normally in continuous HS mode). # # Only flag DAT lane (CLK is continuous HS — LP states not expected). _lp_low_short = ( lp_low_duration_ns is not None and lp_low_duration_ns < 200.0 # below this, LP-low is anomalously brief ) hs_burst_absent = ( hs_amplitude_mv is not None and hs_amplitude_mv < HS_BURST_AMPLITUDE_MIN_MV and ( # Mode A: LP-low normal, rolling-std fired but HS amplitude is sub-threshold. # Require amp ≥ HS_MODE_A_MIN_MV to exclude LP-11-return artifacts: when LP-11 # returns after LP-low without any HS attempt the burst window is pure DC ~0 V # (two LP-11 regions straddling a clean LP-low), giving amp ≈ 0–3 mV. A genuine # weak HS attempt leaves measurable oscillations well above this floor. (lp11_to_hs_ns is not None and lp11_to_hs_ns >= LP_LOW_DUR_MIN_NS and hs_amplitude_mv >= HS_MODE_A_MIN_MV) # Mode A2: rolling-std never fired — HS absent or amplitude below HS_OSC_STD_V; # weak oscillations are misclassified as LP-low, masking the true HS failure or lp11_to_hs_ns is None # Mode B: LP-low anomalously short + low amplitude = marginal HS launch or _lp_low_short # Mode D: LP-low normal (≥ 200 ns) but rolling-std fired on LP-11 falling edge # transition noise (lp11_to_hs < LP_LOW_DUR_MIN_NS). HS amplitude sub-threshold # confirms the HS burst never formed — bridge entered LP-low but returned to LP-11 # without completing SoT. Confirmed: capture 0035 (lp_low=379 ns, amp=19 mV). or (lp11_to_hs_ns is not None and lp11_to_hs_ns < LP_LOW_DUR_MIN_NS and not _lp_low_short) ) ) # Mode C: no LP-11 at all → link silent (but exclude CLK which is always HS) link_silent = ( channel == "dat" and not continuous_hs_clk and not lp11_regions ) flicker_suspect = ( channel == "dat" and ( link_silent or ( lp_transition_valid and ( (lp_low_duration_ns is None or lp_low_duration_ns < FLICKER_LP_LOW_MAX_NS) or hs_burst_absent ) ) ) ) return LPMetrics( timestamp = timestamp, capture_num = capture_num, channel = channel, sample_rate_gsps = round(sample_rate / 1e9, 1), duration_us = round(duration_us, 2), n_samples = len(times), lp11_voltage_v = lp11_voltage_v, lp11_duration_us = lp11_duration_us, lp11_to_hs_ns = lp11_to_hs_ns, lp_low_duration_ns = lp_low_duration_ns, n_hs_bursts = n_hs_bursts, hs_burst_dur_ns = hs_burst_dur_ns, hs_amplitude_mv = hs_amplitude_mv, lp_transition_valid = lp_transition_valid, clk_lp_startup_ok = clk_lp_startup_ok, flicker_suspect = flicker_suspect, warnings = warnings, ) if __name__ == "__main__": import sys data_dir = Path(__file__).parent / "data" if len(sys.argv) > 1: files = [Path(a) for a in sys.argv[1:]] else: files = sorted(data_dir.glob("*.csv"))[:8] # first 8 files as demo for f in files: try: if "_lp_" in f.name: result = analyze_lp_file(f) else: result = analyze_file(f) print(result.summary()) print() except Exception as e: print(f"ERROR {f.name}: {e}")