diff --git a/__pycache__/csv_preprocessor.cpython-312.pyc b/__pycache__/csv_preprocessor.cpython-312.pyc index 2f86e52..4532147 100644 Binary files a/__pycache__/csv_preprocessor.cpython-312.pyc and b/__pycache__/csv_preprocessor.cpython-312.pyc differ diff --git a/__pycache__/rigol_scope.cpython-312.pyc b/__pycache__/rigol_scope.cpython-312.pyc index 6cd3c3f..5a080e7 100644 Binary files a/__pycache__/rigol_scope.cpython-312.pyc and b/__pycache__/rigol_scope.cpython-312.pyc differ diff --git a/csv_preprocessor.py b/csv_preprocessor.py index fc89b1f..cae8694 100644 --- a/csv_preprocessor.py +++ b/csv_preprocessor.py @@ -59,7 +59,13 @@ CLK_LP_LOW_MIN_NS = 300.0 # 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 = 50.0 # mV — below this, no real HS burst is present +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 @@ -969,13 +975,20 @@ def analyze_lp_file(path: Path) -> "LPMetrics": f"(TCLK_PREPARE+TCLK_ZERO minimum) — SN65DSI83 may fail to lock CLK lane" ) - # Flicker suspect: three confirmed failure modes on this hardware: + # 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). @@ -997,8 +1010,16 @@ def analyze_lp_file(path: Path) -> "LPMetrics": hs_amplitude_mv is not None and hs_amplitude_mv < HS_BURST_AMPLITUDE_MIN_MV and ( - # Mode A: LP-low normal, HS never started (rolling-std confirms it) - (lp11_to_hs_ns is not None and lp11_to_hs_ns >= LP_LOW_DUR_MIN_NS) + # 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 ) diff --git a/device_server.py b/device_server.py index a9f0946..8013fbe 100644 --- a/device_server.py +++ b/device_server.py @@ -27,7 +27,7 @@ REGISTER_COMMANDS = [ # --------------------------------------------------------------------------- # SN65DSI83 I2C configuration # --------------------------------------------------------------------------- -SN65_I2C_BUS = 2 # i2c-2 on i.MX 8M Mini — change if bridge is on a different bus +SN65_I2C_BUS = 4 # i2c-4 on this board SN65_I2C_ADDR = 0x2C # SN65DSI83 fixed 7-bit I2C address # Known Samsung DSIM register names (base 0x32E10000, i.MX 8M Mini) @@ -124,25 +124,28 @@ def get_registers(): }), 200 -def _i2c_read_byte(bus: int, addr: int, reg: int) -> int | None: - """Read one byte from an I2C device using i2cget. Returns None on failure.""" +def _i2c_read_byte(bus: int, addr: int, reg: int) -> tuple[int | None, str]: + """Read one byte via i2cget. Returns (value, "") on success or (None, error_str) on failure.""" try: result = subprocess.run( - ["i2cget", "-y", str(bus), f"0x{addr:02x}", f"0x{reg:02x}"], + ["i2cget", "-y", "-f", str(bus), f"0x{addr:02x}", f"0x{reg:02x}"], capture_output=True, text=True, timeout=3 ) if result.returncode == 0: - return int(result.stdout.strip(), 16) - except Exception: - pass - return None + return int(result.stdout.strip(), 16), "" + stderr = result.stderr.strip() or f"exit code {result.returncode}" + return None, stderr + except FileNotFoundError: + return None, "i2cget not found in PATH" + except Exception as e: + return None, str(e) @app.route("/sn65_registers", methods=["GET"]) def get_sn65_registers(): """Read SN65DSI83 CSR 0x0A (PLL/CLK status) and 0xE5 (error flags) via I2C.""" - csr_0a = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A) - csr_e5 = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5) + csr_0a, err_0a = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A) + csr_e5, err_e5 = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5) regs = {} errors = [] @@ -151,10 +154,10 @@ def get_sn65_registers(): regs["csr_0a"] = { "value": f"0x{csr_0a:02x}", "pll_lock": bool(csr_0a & 0x80), # bit 7: PLL_EN_STAT (powered + locked) - "clk_det": bool(csr_0a & 0x40), # bit 6: high-speed CLK lane detected + "clk_det": bool(csr_0a & 0x08), # bit 3: CHA_CLK_DET (HS clock detected) } else: - errors.append("CSR 0x0A: i2cget failed") + errors.append(f"CSR 0x0A: {err_0a}") if csr_e5 is not None: regs["csr_e5"] = { @@ -167,7 +170,7 @@ def get_sn65_registers(): "cha_crc_err": bool(csr_e5 & 0x40), } else: - errors.append("CSR 0xE5: i2cget failed") + errors.append(f"CSR 0xE5: {err_e5}") return jsonify({ "i2c_bus": SN65_I2C_BUS, diff --git a/mipi_test_interactive.py b/mipi_test_interactive.py index c5eba7c..6aa8dc6 100644 --- a/mipi_test_interactive.py +++ b/mipi_test_interactive.py @@ -29,6 +29,10 @@ from datetime import datetime from pathlib import Path import math +import os +import struct +import tempfile +import wave import anthropic import vxi11 @@ -37,8 +41,7 @@ from dotenv import load_dotenv import ai_mgmt import rigol_scope from csv_preprocessor import (analyze_lp_file, LPMetrics, - HS_BURST_AMPLITUDE_MIN_MV, FLICKER_LP_LOW_MAX_NS, - analyze_int_file) + HS_BURST_AMPLITUDE_MIN_MV, FLICKER_LP_LOW_MAX_NS) load_dotenv(Path(__file__).parent / ".env") @@ -565,6 +568,7 @@ def _configure_for_lp(): scope.write(":TRIGger:EDGE:SOURce CHANnel3") scope.write(":TRIGger:EDGE:SLOPe NEGative") scope.write(f":TRIGger:EDGE:LEVel {LP_TRIG_LEVEL:.3f}") + scope.write(":TRIGger:SWEep NORMal") # must wait for real LP-11→LP-01 edge, not auto-fire on HS time.sleep(0.1) @@ -653,19 +657,6 @@ def _fetch_registers(ts: str, iteration: int) -> None: -def _analyze_int_file(ts: str, iteration: int) -> None: - """Print IRQ pin summary and alert if the SN65DSI83 asserted the IRQ line.""" - path = DATA_DIR / f"{ts}_int_{iteration:04d}.csv" - if not path.exists(): - return - try: - m = analyze_int_file(path) - print(m.summary()) - if m.int_asserted: - print(f"\n *** IRQ ASSERTED: SN65DSI83 flagged a bridge error at " - f"capture {iteration:04d} — check CSR 0xE5 for error bits ***\n") - except Exception as e: - print(f" INT ANALYSIS ERROR: {e}") def dual_capture(iteration: int) -> str: @@ -699,12 +690,6 @@ def dual_capture(iteration: int) -> str: print(f" SAVED: {v18_path.name} ({n} samples)") else: print(" RIGOL CH1: waveform read failed — check connection and probe.") - int_path = DATA_DIR / f"{ts}_int_{iteration:04d}.csv" - n_int = rigol_scope.read_int_csv(int_path) - if n_int: - print(f" SAVED: {int_path.name} ({n_int} samples)") - else: - print(" RIGOL CH2: IRQ read failed.") _restore_hs_config() try: requests.put(URL, json={"state": "on"}, timeout=1) @@ -856,6 +841,56 @@ def _append_flicker_log(ts: str, iteration: int, m: LPMetrics) -> None: ]) +def _play_alarm() -> None: + """Play three short beeps using a generated WAV tone.""" + sample_rate = 44100 + freq = 880 + duration = 0.35 + n_samples = int(sample_rate * duration) + samples = [int(32767 * math.sin(2 * math.pi * freq * i / sample_rate)) + for i in range(n_samples)] + packed = struct.pack(f"<{n_samples}h", *samples) + + tmp = None + try: + fd, tmp = tempfile.mkstemp(suffix=".wav") + os.close(fd) + with wave.open(tmp, "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sample_rate) + w.writeframes(packed) + + # os.system inherits the full shell environment (XDG_RUNTIME_DIR, PULSE_SERVER, etc.) + played = False + for cmd in (f"aplay -q {tmp}", f"pw-play {tmp}", f"paplay {tmp}"): + if os.system(f"{cmd} 2>/dev/null") == 0: + played = True + for _ in range(2): + time.sleep(0.2) + os.system(f"{cmd} 2>/dev/null") + break + + if not played: + try: + with open("/dev/tty", "w") as tty: + for _ in range(5): + tty.write("\a") + tty.flush() + time.sleep(0.3) + except Exception: + print("\a" * 5, end="", flush=True) + + except Exception: + print("\a" * 5, end="", flush=True) + finally: + if tmp: + try: + os.unlink(tmp) + except Exception: + pass + + def _analyze_lp_files( ts: str, iteration: int ) -> tuple[list[str], list[LPMetrics]]: @@ -1340,9 +1375,6 @@ def run_interactive_test() -> None: except Exception as e: print(f" TRANSFER ERROR: {e}") - # ── IRQ pin analysis ─────────────────────────────────────────── - _analyze_int_file(ts, iteration) - # ── Rule-based LP analysis ───────────────────────────────────── lp_summaries, suspects = _analyze_lp_files(ts, iteration) @@ -1365,11 +1397,7 @@ def run_interactive_test() -> None: if claude_flicker: # ── Keep display ON — ask operator ───────────────────────── - # Play alarm sound once to alert the operator - subprocess.run( - ["pw-play", "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga"], - check=False, - ) + _play_alarm() print("\n" + "=" * 64) print(" CLAUDE SUSPECTS FLICKER — OBSERVE THE DISPLAY NOW") print("=" * 64) diff --git a/rigol_scope.py b/rigol_scope.py index f618c1e..c9d6b7b 100644 --- a/rigol_scope.py +++ b/rigol_scope.py @@ -4,9 +4,9 @@ rigol_scope.py Controls the Rigol DS1202Z-E at 192.168.45.5 for 1.8 V supply rail monitoring. Called from dual_capture() in mipi_test.py during the LP pass. -The scope is armed (single trigger) just before the Agilent LP capture. +The scope is armed just before the Agilent LP capture. The LP→HS current step droops the 1.8 V rail, triggering the Rigol. -The waveform is then read over SCPI and written directly to the local data/ folder. +The CH1 waveform is read over SCPI and written to the local data/ folder. """ import csv @@ -21,14 +21,6 @@ V18_TIMEBASE = 1e-6 # s/div — 1 µs/div = 10 µs total window V18_TRIG_LEVEL = 1.76 # V — falling-edge trigger on supply droop > 40 mV TRIG_TIMEOUT_S = 15.0 # s — wait this long for Rigol to capture after arming -# CH2 — SN65DSI83 IRQ pin (CMOS output, active HIGH, high-impedance when IRQ_EN=0) -# CSR 0xE0.0 IRQ_EN=0 (default): pin is high-impedance → reads ~0 V (no pull on PCB, normal) -# IRQ_EN=1, no error: driven LOW (~0 V) -# IRQ_EN=1, error asserted: driven HIGH (~1.25 V min per VOH spec) -# No pull-up required — CMOS output drives both high and low. -INT_V_SCALE = 0.2 # V/div — shows 0–~1.8 V range clearly -INT_V_OFFSET = -0.9 # V — centres display on 0.9 V midpoint - rigol: vxi11.Instrument | None = None @@ -70,7 +62,7 @@ def is_connected() -> bool: def configure(): """ - Configure Rigol CH1 for 1.8 V supply monitoring and CH2 for SN65DSI83 INTB pin. + Configure Rigol CH1 for 1.8 V supply monitoring. AUTO trigger sweep: if no droop occurs, scope still captures on timeout so we always get a supply snapshot even when the rail is healthy. """ @@ -84,12 +76,7 @@ def configure(): rigol.write(f":CHANnel1:SCALe {V18_SCALE:.3f}") rigol.write(f":CHANnel1:OFFSet {V18_OFFSET:.3f}") - # CH2 — SN65DSI83 INTB pin (active-low open-drain, external 10 kΩ pull-up to 1.8 V required) - rigol.write(":CHANnel2:DISPlay 1") - rigol.write(":CHANnel2:COUPling DC") - rigol.write(":CHANnel2:PROBe 1") # direct probe, no attenuation - rigol.write(f":CHANnel2:SCALe {INT_V_SCALE:.3f}") - rigol.write(f":CHANnel2:OFFSet {INT_V_OFFSET:.3f}") + rigol.write(":CHANnel2:DISPlay 0") rigol.write(f":TIMebase:MAIN:SCALe {V18_TIMEBASE:.2E}") rigol.write(":TRIGger:MODE EDGE") @@ -101,7 +88,7 @@ def configure(): rigol.write(":RUN") # start acquiring immediately after configure time.sleep(0.2) - print(f"[RIGOL] Configured: CH1=1.8 V rail, CH2=INTB pin, {int(V18_TIMEBASE*1e6)} µs/div, " + print(f"[RIGOL] Configured: CH1=1.8 V rail, {int(V18_TIMEBASE*1e6)} µs/div, " f"trigger <{V18_TRIG_LEVEL} V falling (AUTO sweep, running)") @@ -197,9 +184,3 @@ def read_waveform_csv(path: Path) -> int: return _read_channel_csv("CHANnel1", path, stop_first=True) -def read_int_csv(path: Path) -> int: - """ - Read CH2 (SN65DSI83 INTB pin) waveform from Rigol and write to CSV. - Must be called after read_waveform_csv() — scope is already stopped. - """ - return _read_channel_csv("CHANnel2", path, stop_first=False)