This commit is contained in:
david rice
2026-05-06 15:57:48 +01:00
parent 395e9d6a43
commit 0edb95d7e1
30 changed files with 2493 additions and 0 deletions

49
CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project status
Greenfield. The repository currently contains only `README.md` and `MIPI_FLICKER_SPEC.md` — no Python source, no `requirements.txt`, no tests. All implementation work is driven from the spec. **Read `MIPI_FLICKER_SPEC.md` before making any non-trivial change**; it is the source of truth for module boundaries, REST endpoints, register layouts, and timing thresholds.
When the user asks to "build" or "implement" something, follow the layout in `MIPI_FLICKER_SPEC.md` §3 rather than inventing a new structure.
## Purpose
A two-host test harness that hunts for a MIPI D-PHY flicker/blackout fault on a custom PCB driven by an i.MX 8M Mini → TI SN65DSI83 (DSI→LVDS) bridge. The fault is hypothesised to be timing-violation rounding in the mainline `samsung-dsim` driver that drops several D-PHY parameters below MIPI v1.1 minimums.
The harness loops: power-cycle the display rail, arm the scope, restart video, capture 4-channel waveforms + bridge/SoC registers, decode timings + DSI Lane 0 packets, log a verdict.
## Architecture (from spec §2, §3)
Three top-level concerns; keep them in separate packages:
- **`hardware/`** — instrument I/O only. `scope.py` (DSO80204B over VXI-11), `psu.py` (Siglent SPD3303X-E over VXI-11), `target.py` (HTTP REST client to the i.MX). Each is a thin controller class; no analysis logic.
- **`analysis/`** — pure functions over captured data. `waveform.py` extracts D-PHY timings and decodes Lane 0 DSI packets from CSVs; `registers.py` parses SN65 + DSIM register dumps; `report.py` writes per-run artefacts.
- **`server/app.py`** — Flask REST server that runs **on the i.MX target**, not the host PC. It shells out to `memtool`, `i2cget`, and `/sys/kernel/debug/regmap/...` and exposes `/registers`, `/sn65_registers`, `/sn65_settling`, `/display`, `/video`. The host-side `hardware/target.py` is just a `requests` wrapper around it.
- **`master_loop.py`** — orchestrator only. Reset → arm scope → stimulate → wait for trigger → capture → analyse → save. See spec §9.
`config.py` centralises all IPs, register addresses, MIPI spec minimums, and probe attenuation. Treat it as the single tuning surface.
## Non-obvious invariants
These will silently break the experiment if violated:
1. **Cache-bypass before every SN65 read.** The server **must** `echo 1 > /sys/kernel/debug/regmap/4-002c/cache_bypass` before reading `IRQ_STAT (0xE5)`, every time. Without it, reads return the last-written cached value, not hardware state — flicker events become invisible. Spec §7.2, §15.4. *(Note: spec §7/§11 says bus 2; live hardware on this board has the bridge on bus 4 — `i2cdetect` confirms `UU` at 0x2c on bus 4 only. Code uses bus 4.)*
2. **Scope channels are 50 Ω DC, not 1 MΩ.** The 910R+50R probe divider only gives the documented 19.2× attenuation with 50 Ω termination. Any code that configures the scope must set `:CHANnel<N>:INPut DC50` explicitly. Spec §15.12.
3. **Probe attenuation 19.2× is baked into thresholds.** All voltage thresholds in `analysis/waveform.py` are post-attenuation values (e.g. LP-high > 40 mV ≈ 770 mV on wire). Don't "correct" them back to wire voltages.
4. **UI depends on pixel clock.** `UI_NS = 1e9 / DSI_CLK_HZ` and several spec minimums are `f(UI)`. If `--pixel-clock` is overridden at runtime, recompute `DPHY_SPEC` rather than using stale module-level constants. Spec §15.5.
5. **DSIM PHY_TIMING bit-field layout is undocumented** in the i.MX 8M Mini reference manual. The parser must log raw hex *and* decoded cycle counts so they can be cross-checked against kernel dmesg. Don't assume the layout in spec §10 is correct without dmesg verification. Spec §15.6.
6. **Packet-level decode is the ground truth, not the SN65 error registers.** A flicker can be confirmed only by decoding Lane 0 bytes (Fault A: first long packet payload is all `0x00`; Fault B: lane stalls in LP-11 for ~20 ms). The SN65 `IRQ_STAT` bits are a hint, not a verdict. Spec §1 (Falcon prior art), §13.
## Network topology
Host PC talks to three fixed IPs on the bench LAN — these are wired into `config.py` and the spec, not discovered:
- `192.168.45.3` — Siglent PSU (VXI-11)
- `192.168.45.4` — Keysight DSO80204B scope (VXI-11)
- `192.168.45.8:5000` — i.MX target Flask server (HTTP)
## Commands
No build/test/lint commands exist yet — the project hasn't been scaffolded. Spec §4 lists the dependency set (`python-vxi11`, `requests`, `flask`, `numpy`, `pandas`, `matplotlib`) when a `requirements.txt` is created. Spec §9.3 lists the eventual `master_loop.py` CLI surface (`--max-runs`, `--timeout`, `--pixel-clock`, `--note`, `--no-video`, `--output-dir`).

867
MIPI_FLICKER_SPEC.md Normal file
View File

@@ -0,0 +1,867 @@
# MIPI D-PHY Display Flicker Investigation Suite
## Specification for Claude Code — Build From Scratch
---
## 1. CONTEXT & BACKGROUND
This suite investigates **infrequent vertical jitter/flicker and total display blackout** on a
custom PCB using a **TI SN65DSI83 MIPI-DSI-to-LVDS bridge IC**.
The LVDS output side has been validated with static test images and is considered healthy.
The fault is upstream — on the **MIPI D-PHY physical layer** between the i.MX 8M Mini SoM
and the bridge input.
### Root Cause Hypothesis (from hardware engineer investigation)
The Linux mainline `samsung-dsim` driver uses **"best-fit" (round-to-nearest) rounding** when
converting D-PHY timing parameters from picoseconds to byte-clock cycles. Several parameters
consistently round **below the MIPI D-PHY v1.1 specification minimums**, causing the SN65DSI83
to occasionally fail to latch the Start-of-Transmission (SoT) sequence, producing a frame jump
or blackout.
Key violating parameters at 72 MHz pixel clock (432 Mbps DSI, byte clock = 54 MHz):
| Parameter | Spec Min (ps) | Mainline "Best Fit" (ps) | Violation? |
|-----------------|---------------|--------------------------|------------|
| `clk_prepare` | 38,000 | 37,037 | YES |
| `clk_zero` | 262,000* | 259,259 | YES |
| `clk_trail` | 60,000 | 55,555 | YES |
| `hs_zero` | 118,890 | 111,111 | YES |
| `hs_trail` | 97,040 | 92,592 | YES |
| `hs_exit` | 100,000 | 92,592 | YES |
> *`clk_prepare + clk_zero` combined minimum is 300,000 ps per MIPI D-PHY v1.1 Table 14.
A "round-up" patch was applied (u-boot `dsi-tweak` bit 2) and reduces flicker frequency
but does not fully eliminate it. Additional per-parameter padding registers are available.
The goal of this suite is to **automate stress testing across parameter combinations** and
**correlate scope-captured D-PHY waveforms with bridge error registers** to find the exact
boundary condition that triggers a flicker event.
### Prior Art: Falcon Board MIPI Analysis (S. Bouriot, May 2024)
A previous investigation on the **Falcon board** (different product, same SN65DSI83 bridge,
lower pixel clock ~52 MHz) manually decoded MIPI DSI packets from scope captures during a
spread-spectrum-induced shift fault. That analysis is directly applicable here. Key findings:
**What was proven by direct packet decoding:**
The fault manifests at the **DSI packet payload layer**, not just the PHY layer. Two distinct
failure modes were identified by decoding Lane 0 data bytes:
**Failure Mode A — Pixel Data Offset (Shifted Display):**
- In a healthy frame, the first long packet (0x3E = Packed Pixel Stream, 24-bit RGB) contains
the correct pixel colour bytes (e.g. 0xD1, 0xB5, 0x90) starting from byte 0 of the payload.
- In the fault state, the **first long packet contains only 0x00 bytes**. The correct pixel
data (0xD1, 0xB5, 0x90) appears **in the second long packet, offset into the line**, not
at the start.
- This means the bridge is rendering pixel N's data at pixel N+offset position — causing the
visible horizontal/vertical shift.
- The packet **header** (0x21 H-Sync Start, 0x3E Data ID) is **identical** in both good and
fault states — the fault is in the payload, not the framing.
**Failure Mode B — Lane Stall (Flickering/Blackout):**
- The data lane enters **LP-11 (Stop state) for ~20 ms** at ~20 ms intervals.
- No pixel data packets are transmitted during this period.
- This is **not observed** in the healthy display state — it is definitively abnormal.
- This corresponds to the "total blackout" failure mode seen on the Nexio board.
**What the Falcon analysis could NOT determine:**
- Whether the fault originates at the DSI transmitter (i.MX), the bridge input, or is a
timing/PLL issue. The Falcon fault was triggered by spread spectrum; the Nexio fault
occurs without spread spectrum, suggesting a different (or additional) root cause.
**Implication for this suite:**
The analysis must operate at **two levels simultaneously**:
1. **PHY level** — scope capture of LP/HS transitions, measuring T_HS_PREPARE etc.
2. **Packet level** — decoding Lane 0 differential data to verify pixel bytes are in the
correct position within each long packet.
The packet-level decode is the **ground truth** for whether a flicker event has occurred,
independent of what the SN65DSI83 error registers report.
---
## 2. SYSTEM ARCHITECTURE
```
┌─────────────────────────────────────────────────────────┐
│ Host PC (Python 3.x) │
│ │
│ master_loop.py │
│ ├── TargetController → HTTP REST → 192.168.45.8 │
│ ├── ScopeController → VXI-11 → 192.168.45.4 │
│ └── PSUController → VXI-11 → 192.168.45.3 │
└─────────────────────────────────────────────────────────┘
Target (192.168.45.8:5000)
└── i.MX 8M Mini (Digi ConnectCore 8M Mini SoM)
├── samsung-dsim driver → MIPI D-PHY 4-lane
├── TI SN65DSI83 bridge → I2C bus 2 @ 0x2C
└── Flask REST server → /registers, /sn65_registers,
/sn65_settling, /display, /video
Scope (192.168.45.4)
└── Agilent/Keysight DSO80204B (2 GHz, LXI)
CH1: MIPI CLK+ (19.2× atten, DC 50Ω)
CH2: MIPI CLK- (19.2× atten, DC 50Ω)
CH3: MIPI DAT0+ (19.2× atten, DC 50Ω)
CH4: MIPI DAT0- (19.2× atten, DC 50Ω)
PSU (192.168.45.3)
└── Siglent SPD3303X-E (LXI, SCPI)
CH1: Display 3.3V rail
```
---
## 3. PROJECT FILE STRUCTURE
```
mipi_flicker/
├── SPEC.md ← This document
├── requirements.txt
├── config.py ← All constants, IPs, thresholds
├── hardware/
│ ├── __init__.py
│ ├── scope.py ← ScopeController (VXI-11, DSO80204B)
│ ├── psu.py ← PSUController (VXI-11, SPD3303X-E)
│ └── target.py ← TargetController (HTTP REST)
├── analysis/
│ ├── __init__.py
│ ├── waveform.py ← D-PHY timing extraction from CSV
│ ├── registers.py ← SN65 / DSIM register parsing & flagging
│ └── report.py ← Per-run HTML/JSON/CSV report generation
├── server/
│ ├── app.py ← Flask REST server (deploy on i.MX8)
│ └── hw_interface.py ← memtool, i2cget, display/video control
├── master_loop.py ← Main entry point — the flicker hunt loop
└── captures/ ← Auto-created; one subfolder per run
└── run_001_20260505_143022/
├── waveform_ch1.csv
├── waveform_ch2.csv
├── waveform_ch3.csv
├── waveform_ch4.csv
├── registers.json
└── summary.txt
```
---
## 4. PYTHON DEPENDENCIES (`requirements.txt`)
```
python-vxi11>=0.9
requests>=2.28
flask>=3.0 # server side only
numpy>=1.24
pandas>=2.0
matplotlib>=3.7 # optional, for waveform plots
```
---
## 5. CONFIGURATION (`config.py`)
```python
# Network
TARGET_IP = "192.168.45.8"
TARGET_PORT = 5000
SCOPE_IP = "192.168.45.4"
PSU_IP = "192.168.45.3"
# Scope hardware
SCOPE_CHANNELS = {
"CLK_P": 1, # MIPI Clock Lane +
"CLK_N": 2, # MIPI Clock Lane -
"DAT0_P": 3, # MIPI Data Lane 0 +
"DAT0_N": 4, # MIPI Data Lane 0 -
}
PROBE_ATTENUATION = 19.2 # 910R + 50R divider, calibrated
SCOPE_TIMEBASE = 5e-9 # 5 ns/div — resolves ~430 MHz transitions
SCOPE_POINTS = 500_000
TRIGGER_CHANNEL = 3 # DAT0+ — catches LP-01→LP-00 SoT entry
TRIGGER_LEVEL_V = 0.05 # 50 mV after 19.2× attenuation factor
# (represents ~960 mV on actual signal)
TRIGGER_SLOPE = "NEGative"
# PSU
PSU_CHANNEL_DISPLAY = 1
PSU_DISPLAY_VOLTAGE = 3.3
PSU_DISPLAY_CURRENT = 1.0
PSU_POWER_CYCLE_DELAY_S = 2.0
# Pixel clock & DSI parameters
PIXEL_CLOCK_HZ = 72_000_000
DSI_LANES = 4
BITS_PER_PIXEL = 24
# DSI clock = pixel_clock * bpp / lanes (DDR, so /2 for freq)
DSI_CLK_HZ = PIXEL_CLOCK_HZ * BITS_PER_PIXEL // DSI_LANES # 432 MHz
BYTE_CLK_HZ = DSI_CLK_HZ // 8 # 54 MHz
UI_NS = 1e9 / DSI_CLK_HZ # ~2.315 ns
# MIPI D-PHY v1.1 timing minimums (nanoseconds)
# Table 14, Section 9 — all are MINIMUM values; violations cause SoT errors
DPHY_SPEC = {
"t_lpx": 50.0,
"t_clk_prepare": 38.0,
"t_clk_zero": 262.0, # clk_prepare + clk_zero >= 300 ns
"t_clk_prepare_plus_zero": 300.0, # combined hard limit
"t_clk_trail": 60.0,
"t_clk_post": 60.0 + 52 * UI_NS,
"t_hs_prepare": 40.0 + 4 * UI_NS,
"t_hs_zero": 145.0 + 10 * UI_NS,
"t_hs_trail": max(8 * UI_NS, 60.0 + 4 * UI_NS),
"t_hs_exit": 100.0,
}
# SN65DSI83 I2C
SN65_I2C_ADDR = 0x2C
SN65_I2C_BUS = 2 # /dev/i2c-2 on target
SN65_REG_IRQ = 0xE5 # IRQ_STAT — volatile, must bypass cache
SN65_REG_PLL = 0x0A # RC_LVDS_PLL — bit 7 = PLL lock
SN65_REG_CLK = 0x0B # RC_LVDS_CLK — bit 0 = clock detect
# Error bit masks in SN65 REG_IRQ (0xE5)
SN65_ERR_SYNCH = (1 << 3) # CHA_SYNCH_ERR
SN65_ERR_SOT = (1 << 4) # CHA_SOT_ERR
SN65_ERR_UNC = (1 << 6) # CHA_UNC_ECC_ERR
SN65_FLICKER_MASK = SN65_ERR_SYNCH | SN65_ERR_SOT | SN65_ERR_UNC
# DSIM PHY timing registers (i.MX8M Mini, samsung-dsim)
DSIM_PHYTIMING_BASE = 0x32E100B4 # PHY_TIMING offset 0
DSIM_PHYTIMING1 = 0x32E100B8 # PHY_TIMING1 offset 4
DSIM_PHYTIMING2 = 0x32E100BC # PHY_TIMING2 offset 8
# Tweak parameters available via u-boot env (for reference/logging)
TWEAK_BIT_FIFO_FLUSH = (1 << 0)
TWEAK_BIT_ROUND_UP = (1 << 2)
# Output
CAPTURE_ROOT = "captures"
```
---
## 6. MODULE SPECIFICATIONS
### 6.1 `hardware/psu.py` — `PSUController`
**Purpose:** Control the Siglent SPD3303X-E to power-cycle the display 3.3 V rail.
**Library:** `python-vxi11`
**SCPI command reference:**
```
*IDN? → identity string
CH1:VOLTage <V> → set voltage
CH1:CURRent <A> → set current limit
OUTPut CH1,ON / OFF → enable/disable output
MEASure:VOLTage? CH1 → read back actual voltage
MEASure:CURRent? CH1 → read back actual current
```
**Methods required:**
| Method | Signature | Description |
|--------|-----------|-------------|
| `__init__` | `(ip: str)` | Connect via vxi11, verify IDN, configure CH1 to 3.3 V / 1.0 A |
| `output_on` | `()` | `OUTPut CH1,ON` |
| `output_off` | `()` | `OUTPut CH1,OFF` |
| `power_cycle` | `(delay_s=2.0)` | Off → sleep → On |
| `measure` | `() → dict` | Returns `{"voltage_v": float, "current_a": float}` |
| `close` | `()` | Disconnect instrument |
---
### 6.2 `hardware/scope.py` — `ScopeController`
**Purpose:** Configure the DSO80204B, arm triggers, detect acquisitions, and download waveform data.
**Library:** `python-vxi11`
**Key SCPI commands for DSO80204B:**
```
*RST → factory reset
:RUN / :STOP / :SINGle → run modes
:CHANnel<N>:DISPlay ON
:CHANnel<N>:INPut DC50 → 50Ω termination (REQUIRED)
:CHANnel<N>:PROBe 19.2 → attenuation ratio
:CHANnel<N>:SCALe 0.05 → 50 mV/div
:CHANnel<N>:OFFSet 0.0
:CHANnel<N>:LABel '<name>'
:TIMebase:SCALe 5E-9 → 5 ns/div
:TIMebase:POSition 0
:TIMebase:REFerence CENTer
:TRIGger:MODE EDGE
:TRIGger:EDGE:SOURce CHANnel<N>
:TRIGger:EDGE:SLOPe NEGative
:TRIGger:EDGE:LEVel <V>
:TRIGger:SWEep NORMal
:ACQuire:MODE RTIMe
:ACQuire:INTerpolate ON
:ACQuire:POINts 500000
:DISPlay:LAYout STACKed
:TER? → trigger event register (1 = triggered)
:WAVeform:SOURce CHANnel<N>
:WAVeform:FORMat ASCii
:WAVeform:STReaming ON
:WAVeform:DATA? → returns CSV waveform data
:WAVeform:PREamble? → returns x-increment, x-origin etc.
```
**Methods required:**
| Method | Signature | Description |
|--------|-----------|-------------|
| `__init__` | `(ip: str)` | Connect, verify IDN |
| `setup` | `()` | Full channel + timebase + trigger configuration per `config.py` |
| `arm_single` | `()` | `:SINGle` — waits for one trigger event then holds |
| `wait_for_trigger` | `(timeout_s=30) → bool` | Poll `:TER?` every 100 ms; return True on trigger |
| `download_waveform` | `(channel: int) → pd.DataFrame` | Fetch ASCII waveform, apply preamble scaling, return DataFrame with columns `time_s`, `voltage_v` |
| `download_all` | `() → dict[str, pd.DataFrame]` | Download all 4 channels; keys: `CLK_P`, `CLK_N`, `DAT0_P`, `DAT0_N` |
| `close` | `()` | Disconnect |
**Important:** After downloading a waveform, apply the `x_increment` and `x_origin` from `:WAVeform:PREamble?` so timestamps are absolute seconds.
---
### 6.3 `hardware/target.py` — `TargetController`
**Purpose:** HTTP REST client for the i.MX 8M Mini target.
**Library:** `requests`
**Endpoints (all at `http://192.168.45.8:5000`):**
| Method | Endpoint | Payload | Returns |
|--------|----------|---------|---------|
| GET | `/registers` | — | JSON: DSIM PHY_TIMING registers |
| GET | `/sn65_registers` | — | JSON: Full SN65DSI83 register map |
| GET | `/sn65_settling` | — | JSON: Register poll over settling window |
| PUT | `/display` | `{"state": "on"\|"off"}` | `{"ok": true}` |
| PUT | `/video` | `{"action": "start"\|"stop", "mode": "static-pink"}` | `{"ok": true}` |
**Methods required:**
| Method | Signature | Description |
|--------|-----------|-------------|
| `__init__` | `(ip: str, port: int)` | Build base URL, verify connectivity with a GET /registers |
| `get_dsim_registers` | `() → dict` | GET /registers |
| `get_sn65_registers` | `() → dict` | GET /sn65_registers — server MUST bypass regmap cache before reading |
| `get_sn65_settling` | `() → dict` | GET /sn65_settling |
| `display_on` | `()` | PUT /display {"state": "on"} |
| `display_off` | `()` | PUT /display {"state": "off"} |
| `video_start` | `(mode="static-pink")` | PUT /video {"action": "start", "mode": mode} |
| `video_stop` | `()` | PUT /video {"action": "stop"} |
---
## 7. REST SERVER SPECIFICATION (`server/app.py`)
**Deploy on i.MX 8M Mini. Python 3.x, Flask.**
### 7.1 GET `/registers`
Executes on the SoM:
```bash
memtool md -l 0x32e100b4+0x0c
```
Parse and return:
```json
{
"PHY_TIMING": "0x00000305",
"PHY_TIMING1": "0x020e0a03",
"PHY_TIMING2": "0x00030605",
"raw_hex": "00000305 020e0a03 00030605"
}
```
### 7.2 GET `/sn65_registers`
**Critical:** Before any I2C read, execute:
```bash
echo 1 > /sys/kernel/debug/regmap/2-002c/cache_bypass
```
Then read the SN65DSI83 register file:
```bash
cat /sys/kernel/debug/regmap/2-002c/registers
```
Parse the output and return a JSON dict of `{ "reg_hex": value_hex }` pairs.
**Always explicitly include and flag these registers:**
```json
{
"registers": { "00": "35", ..., "0a": "85", "e5": "00" },
"pll_locked": true, // reg 0x0A bit 7
"clk_detected": true, // reg 0x0B bit 0
"irq_stat_raw": "0x00", // reg 0xE5 raw value
"sot_err": false, // reg 0xE5 bit 4
"synch_err": false, // reg 0xE5 bit 3
"unc_ecc_err":false // reg 0xE5 bit 6
}
```
### 7.3 GET `/sn65_settling`
Poll GET `/sn65_registers` every 100 ms for 2 seconds after power-up.
Return array of snapshots with timestamps. This catches transient error spikes
during the LP→HS initialization handshake.
### 7.4 PUT `/display`
```json
{"state": "on"} echo 0 > /sys/class/graphics/fb0/blank
{"state": "off"} echo 4 > /sys/class/graphics/fb0/blank
```
### 7.5 PUT `/video`
```json
{"action": "start", "mode": "static-pink"}
```
Start a GStreamer pipeline (or equivalent) that outputs a solid pink
framebuffer image to drive continuous DSI traffic. Example pipeline:
```bash
gst-launch-1.0 videotestsrc pattern=solid-color foreground-color=0xFFFF69B4 \
! video/x-raw,width=1280,height=800,framerate=60/1 \
! fbdevsink device=/dev/fb0
```
`{"action": "stop"}` → kill the pipeline.
---
## 8. ANALYSIS MODULE SPECIFICATIONS
### 8.1 `analysis/waveform.py`
**Input:** Dict of DataFrames from `ScopeController.download_all()`
**Purpose:** Extract D-PHY timing parameters from raw voltage waveforms.
#### 8.1.1 Signal Reconstruction
- Compute differential signals: `CLK_DIFF = CLK_P - CLK_N`, `DAT0_DIFF = DAT0_P - DAT0_N`
- Compute common-mode: `CLK_CM = (CLK_P + CLK_N) / 2`
#### 8.1.2 Lane State Detection
The D-PHY lane state machine uses **single-ended** voltage levels for LP states
and **differential** for HS. Thresholds (after 19.2× probe):
| State | CLK_P | CLK_N | Interpretation |
|-------|-------|-------|----------------|
| LP-11 (Stop/Idle) | ~62 mV | ~62 mV | Both high (~1.2 V on wire) |
| LP-01 (HS Request)| ~0 mV | ~62 mV | Dp low, Dn high |
| LP-00 (Bridge) | ~0 mV | ~0 mV | Both low (Prepare phase) |
| HS-0 (HS burst) | diff ~±10 mV | — | Low-swing differential |
> Threshold for LP "high": > 40 mV post-attenuation (~770 mV on wire).
> Threshold for LP "low": < 10 mV post-attenuation (~192 mV on wire).
#### 8.1.3 Timing Measurements Required
**`measure_t_lpx(data_lane_p, data_lane_n) → float (ns)`**
- Measure duration of LP-01 state on data lane (Dp low, Dn high).
**`measure_t_hs_prepare(data_lane_p, data_lane_n) → float (ns)`**
- Measure duration of LP-00 state on data lane before HS-0 transition.
**`measure_t_clk_prepare(clk_p, clk_n) → float (ns)`**
- Measure duration of LP-00 on clock lane before HS clock starts.
**`measure_t_clk_zero(clk_p, clk_n) → float (ns)`**
- Measure duration of HS-0 (differential zero) on clock lane before
first clock toggle. Start: exit LP-00. End: first differential edge.
**`measure_t_clk_prepare_plus_zero(clk_p, clk_n) → float (ns)`**
- Combined `t_clk_prepare + t_clk_zero`. **Must be >= 300 ns.**
**`measure_t_hs_zero(data_lane_p, data_lane_n) → float (ns)`**
- Measure HS-0 preamble duration on data lane before SoT sync byte (00011101).
#### 8.1.4 Spec Validation
**`check_spec_compliance(measurements: dict) → dict`**
- Compare each measured value against `DPHY_SPEC` from `config.py`.
- Return dict with per-parameter: `{"measured_ns": float, "min_ns": float, "pass": bool, "margin_ns": float}`
---
### 8.2 `analysis/registers.py`
**`parse_sn65(reg_json: dict) → dict`**
- Extract and interpret all flags from `/sn65_registers` response.
- Return structured dict including `flicker_detected: bool` based on `SN65_FLICKER_MASK`.
**`parse_dsim(reg_json: dict) → dict`**
- Decode PHY_TIMING, PHY_TIMING1, PHY_TIMING2 into individual cycle counts.
- PHY_TIMING (0x32E100B4): bits [7:4]=lpx, bits[3:0]=hs_exit
- PHY_TIMING1 (0x32E100B8): bits[31:24]=clk_zero, bits[23:16]=clk_post,
bits[15:8]=clk_trail, bits[7:0]=clk_prepare
- PHY_TIMING2 (0x32E100BC): bits[23:16]=hs_prepare, bits[15:8]=hs_zero,
bits[7:0]=hs_trail
- Convert cycles to nanoseconds: `ns = cycles / BYTE_CLK_HZ * 1e9`
---
### 8.3 `analysis/report.py`
Per-run outputs saved to `captures/run_NNN_YYYYMMDD_HHMMSS/`:
| File | Format | Content |
|------|--------|---------|
| `waveform_ch1.csv` through `ch4.csv` | CSV | Raw scope data, columns: `time_s`, `voltage_v` |
| `registers.json` | JSON | DSIM + SN65 register snapshot at trigger time |
| `timing_analysis.json` | JSON | Measured D-PHY timings + spec compliance results |
| `summary.txt` | Text | Human-readable pass/fail + key measurements |
| `flicker_log.csv` | CSV (appended) | Master log — one row per run |
**`flicker_log.csv` columns:**
```
run_id, timestamp, flicker_detected, sot_err, synch_err, pll_locked,
t_lpx_ns, t_hs_prepare_ns, t_hs_prepare_pass,
t_clk_prepare_ns, t_clk_zero_ns, t_clk_prep_plus_zero_ns, t_clk_prep_zero_pass,
phy_timing_raw, phy_timing1_raw, phy_timing2_raw,
notes
```
---
## 9. MASTER LOOP (`master_loop.py`)
### 9.1 Startup Sequence
```
1. Instantiate PSUController, ScopeController, TargetController
2. PSU: Configure CH1 to 3.3V / 1.0A, output OFF
3. Target: PUT /display off, PUT /video stop
4. Scope: setup() — full channel + trigger configuration
5. Log start time and initial DSIM register state
```
### 9.2 Main Loop (runs until KeyboardInterrupt or max_runs reached)
```
FOR each run:
[RESET PHASE]
1. target.display_off()
2. target.video_stop()
3. psu.output_off()
4. sleep(PSU_POWER_CYCLE_DELAY_S)
[ARM PHASE]
5. scope.arm_single()
6. Create timestamped run directory under captures/
[STIMULUS PHASE]
7. psu.output_on()
8. sleep(0.5) # allow rails to stabilise
9. target.display_on()
10. target.video_start(mode="static-pink")
[ACQUIRE PHASE]
11. triggered = scope.wait_for_trigger(timeout_s=30)
12. If NOT triggered: log "TIMEOUT", continue to next run
[CAPTURE PHASE]
13. waveforms = scope.download_all() # all 4 channels
14. sn65_data = target.get_sn65_registers()
15. dsim_data = target.get_dsim_registers()
16. settling = target.get_sn65_settling()
[ANALYSIS PHASE]
17. timings = waveform.measure_all(waveforms)
18. spec_pass = waveform.check_spec_compliance(timings)
19. sn65_parsed = registers.parse_sn65(sn65_data)
20. dsim_parsed = registers.parse_dsim(dsim_data)
[DETECT PHASE]
21. flicker_detected = sn65_parsed["flicker_detected"]
22. If flicker_detected: print "*** FLICKER EVENT CAPTURED ***"
[SAVE PHASE]
23. Save all waveform CSVs
24. Save registers.json, timing_analysis.json, summary.txt
25. Append row to flicker_log.csv
[REPORT PHASE]
26. Print one-line status: run ID, flicker Y/N, key timing margins
END LOOP
```
### 9.3 Command-Line Arguments
```
python master_loop.py [options]
--max-runs N Stop after N captures (default: unlimited)
--timeout S Scope trigger timeout per run in seconds (default: 30)
--pixel-clock HZ Override pixel clock (default: 72000000)
--note "string" Append a note to every log row (e.g. "dsi-tweak=5")
--no-video Skip PUT /video (test display blank/unblank only)
--output-dir PATH Override captures root directory
```
---
## 10. DSIM REGISTER DECODE REFERENCE
At 72 MHz pixel clock (DSI = 432 MHz, byte clock = 54 MHz):
### Expected "Round Up" values (dsi-tweak bit 2 set):
```
PHY_TIMING = 0x00000306
[7:4] hs_exit = 0x0 = 0 cycles ← NOTE: check kernel log for actual value
[3:0] lpx = 6 = 6 cycles → 111 ns (spec ≥50 ns ✓)
PHY_TIMING1 = 0x03120a04
[31:24] clk_zero = 0x03 = 3? ← cross-check with kernel log
[23:16] clk_post = 0x12 = 18 cycles
[15:8] clk_trail = 0x0a = 10 cycles
[7:0] clk_prepare = 0x04 = 4 cycles → 74 ns (spec ≥38 ns ✓)
PHY_TIMING2 = 0x00040707
[23:16] hs_prepare = 0x04 = 4 cycles → 74 ns
[15:8] hs_zero = 0x07 = 7 cycles → 130 ns
[7:0] hs_trail = 0x07 = 7 cycles → 130 ns
```
> **Note:** The exact bit-field layout is **not well-documented** in the i.MX8M Mini
> reference manual (see the TODO comment in `samsung-dsim.c`). The register parser
> should log the raw hex values AND the decoded cycle counts so they can be
> cross-referenced against kernel log output for verification.
---
## 11. SN65DSI83 REGISTER REFERENCE
**I2C address:** 0x2C (ADDR pin = GND)
**Bus:** i2c-2 on target (`/dev/i2c-2`)
| Register | Address | Key Bits | Description |
|----------|---------|----------|-------------|
| RC_RESET | 0x09 | [0] = SOFT_RESET | Write 1 to reset |
| RC_LVDS_PLL | 0x0A | [7] = PLL_EN, read-back = PLL_LOCK | Volatile — reads hardware |
| RC_LVDS_CLK | 0x0B | [0] = CLK_DETECT | Clock detected on DSI input |
| IRQ_STAT | 0xE5 | [6]=UNC_ECC, [4]=SOT_ERR, [3]=SYNCH_ERR, [1]=CRC_ERR | **Volatile** — must bypass cache |
**Cache bypass (execute before reading IRQ_STAT):**
```bash
echo 1 > /sys/kernel/debug/regmap/2-002c/cache_bypass
```
**Manual I2C read (fallback if regmap unavailable):**
```bash
i2cget -y -f 2 0x2c 0xe5
```
**Test pattern commands (useful for isolating LVDS vs DSI fault):**
```bash
# Enable test pattern (removes DSI as variable)
i2cset -y -f 4 0x2c 0x3c 0x10
# Disable test pattern
i2cset -y -f 4 0x2c 0x3c 0x00
```
---
## 12. U-BOOT TUNING PARAMETERS (Reference)
These are set on the SoM via U-Boot environment variables. The REST server
can optionally expose a PUT `/uboot_env` endpoint to change them between
test runs (requires reboot), or they can be set manually between test sessions.
```bash
# Pixel clock
setenv flb_dtovar/lvds-freq 72000000
# DSI clock override (should be >= 6 × lvds-freq for 24bpp/4-lane)
setenv flb_dtovar/dsi-freq 432000000
# DSI tweak bitmask
# Bit 0: FIFO flush on VSync (recommended ON)
# Bit 2: Round-up rounding (recommended ON)
setenv flb_dtovar/dsi-tweak 5
# Per-parameter extra cycle padding
setenv flb_dtovar/dsi-phy-extra-clk_zero 3
setenv flb_dtovar/dsi-phy-extra-hs_prepare 1
setenv flb_dtovar/dsi-phy-extra-hs_trail 1
# Other available padding parameters
# flb_dtovar/dsi-phy-extra-lpx
# flb_dtovar/dsi-phy-extra-hs_exit
# flb_dtovar/dsi-phy-extra-clk_prepare
# flb_dtovar/dsi-phy-extra-clk_post
# flb_dtovar/dsi-phy-extra-clk_trail
# flb_dtovar/dsi-phy-extra-hs_zero
saveenv
reset
```
> **Note:** Changing `lvds-freq` breaks U-Boot splash (hardcoded PLL table).
> `dsi-tweak` / padding params do NOT require matching splash changes.
---
## 13. PACKET-LEVEL DECODE (Lane 0 Data Analysis)
This section defines how the analysis script should decode the DSI packet stream from
the raw Lane 0 differential waveform. This is the **ground truth** fault detector,
validated by the Falcon board manual decode (S. Bouriot, May 2024).
### 13.1 DSI Long Packet Structure (24-bit RGB, Data Type 0x3E)
```
1 byte 2 bytes 1 byte WORD COUNT bytes 2 bytes
[DATA TYPE][WORD COUNT][ ECC ][ 24bpp Pixel Stream ... ][ CRC ]
<-------- Packet Header ------> <-- Packet Payload ----> Footer
```
With 4 data lanes, bytes are distributed across lanes in round-robin order.
Lane 0 carries bytes 0, 4, 8, 12... of the serialised packet stream.
For a 1024-pixel-wide line (24bpp, 4 lanes):
- Total payload bytes = 1024 x 3 = 3072
- Bytes on Lane 0 = 768
- Theoretical clock edges per line (header excluded) = 1024 x 3 x 8 / 4 = **6144**
(Falcon measured ~6150 OK / ~6146 KO — within measurement error, line count consistent)
### 13.2 Short Packet Types Observed
- `0x21` = Sync Event, H Sync Start (short packet preceding each long packet)
- `0x3E` = Packed Pixel Stream, 24-bit RGB (long packet, one per display line)
- Each frame is preceded by a group of short packets (~39 LP11->LP0x transitions per frame,
per Falcon measurement on a 768-line display)
### 13.3 Healthy Frame Signature
Lane 0 byte sequence after SoT:
```
0x21 → H-Sync short packet (repeated ~39 times as frame preamble)
0x3E → Long packet Data Type (start of line 1 RGB payload)
[payload byte 0]: non-zero R/G/B value present IMMEDIATELY from start of payload
```
Example with test pixels (R=0xD1, G=0xB5, B=0x90):
```
Lane 0 payload bytes: D1 B5 90 00 D1 B5 90 00 ... (pixel 0 RR, pixel 1 GG, pixel 2 BB...)
```
### 13.4 Fault Signature A — Pixel Data Offset (Shifted Display)
First long packet after frame preamble:
```
0x3E → Data Type correct
payload bytes 0..N: 00 00 00 00 00 00 ... ← ALL ZEROS, pixel data absent
```
Second long packet (~995 µs later per Falcon measurement):
```
payload at offset ~15 µs: D1 B5 90 ← pixel data arrives in WRONG packet/position
```
This is confirmed by Falcon analysis as the direct cause of visible horizontal shift.
The packet headers are identical in OK and KO states — the fault is purely in payload
position, not in framing or Data Type bytes.
### 13.5 Fault Signature B — Lane Stall (Flickering/Blackout)
- Lane 0 differential collapses to ~0 V (LP-11 Stop state) for **~20 ms** at ~20 ms intervals
- Zero HS bursts transmitted during stall window
- Abnormal — never observed in healthy display operation
- Corresponds to the Nexio "total blackout" failure mode requiring power cycle to recover
- May co-occur with Fault A (shifted display + flicker seen simultaneously on Falcon)
### 13.6 Packet Decode Implementation Notes
`analysis/waveform.py` should implement `decode_lane0_packets()`:
1. Compute `DAT0_DIFF = DAT0_P - DAT0_N`
2. Identify HS burst boundaries: look for differential swing exceeding ±8 mV (post 19.2x
attenuation) after a period of LP-00 (both lines near 0 V)
3. Use recovered clock from `CLK_DIFF` edges to sample data bits (DDR — both edges)
4. Deserialise bytes MSB-first; check for Lane 0 SoT sync word (`0xB8` after the
`LP-11 -> LP-01 -> LP-00 -> HS-0` preamble)
5. Extract Data Type byte, Word Count (2 bytes), ECC (1 byte), then payload
6. Return structured list: `[{burst_idx, timestamp_s, data_type, word_count, payload_hex}]`
**Scope window constraint:** At 5 ns/div with 500 kpts, the capture window is ~2.5 µs —
sufficient for SoT + packet header + first ~200 bytes of payload. For full line decode,
set timebase to 500 ns/div or use the scope's segmented memory mode if available.
The header check alone (first 4 payload bytes = 0x00 vs non-zero) is sufficient to
classify Fault A without needing to decode the entire line.
---
## 14. KNOWN FAILURE MODES & EXPECTED SIGNATURES
### 14.1 Transient Flicker (Vertical Jitter)
- **Frequency:** Every few seconds to minutes depending on pixel clock
- **Scope signature:** Normal-looking SoT sequence but `t_hs_prepare` or
`t_clk_prepare + t_clk_zero` just below spec minimum
- **Register signature:** REG_IRQ 0xE5 bits 3 or 4 set transiently
- **Packet signature:** First long packet payload starts with 0x00 bytes;
pixel data found offset into second packet (confirmed by Falcon analysis)
- **Recovery:** Automatic — next valid frame corrects sync
### 14.2 Total Display Blackout
- **Frequency:** Rare; requires power cycle or driver unbind/rebind to recover
- **Cause:** DSI controller enters hang state; display power rail remains up
(shared with touchscreen, light sensor, LED)
- **Recovery steps:**
```bash
echo 3-0023 > /sys/bus/i2c/devices/i2c-3/3-0023/driver/unbind
echo 3-002d > /sys/bus/i2c/devices/i2c-3/3-002d/driver/unbind
echo 3-0041 > /sys/bus/i2c/devices/i2c-3/3-0041/driver/unbind
echo 4 > /sys/class/graphics/fb0/blank
echo 0 > /sys/class/graphics/fb0/blank
```
- **Scope signature:** LP lines fail to return from LP-00 to LP-11 after burst;
lane stays in LP-11 for ~20 ms intervals (confirmed by Falcon analysis)
### 14.3 Pixel Clock Sensitivity Pattern
Empirically observed — some clock frequencies are stable, others cause
consistent jitter. The boundary appears non-monotonic:
| Pixel Clock | Behaviour |
|-------------|-----------|
| 70 MHz | Stable |
| 71 MHz | Jitter |
| 72 MHz | Stable (current setting) |
| 72.4 MHz | Jitter (datasheet "typical") |
| 73 MHz | Stable |
| 74 MHz | Jitter |
| 7576 MHz | Stable |
This non-monotonic pattern suggests **resonance interaction** between the DSI PLL
and the SN65DSI83 PLL, or undocumented constraints in the samsung-dsim PHY.
The scope capture at multiple clock frequencies may reveal whether `t_clk_prepare +
t_clk_zero` tracks the instability boundary.
---
## 15. IMPORTANT IMPLEMENTATION NOTES
1. **Always use `DC 50Ω` input on scope** — 1 MΩ input with MIPI-speed signals
will produce useless ringing. The 910R + 50R divider is only correct with 50Ω input.
2. **19.2× attenuation:** The probe factor is `(910 + 50) / 50 = 19.2`. LP-11 state
(~1.2 V on wire) appears as ~62.5 mV at scope. HS swing (~200 mV on wire)
appears as ~10.4 mV. Set scope vertical scale to 50 mV/div.
3. **Trigger level 50 mV (post-attenuation):** This catches the falling edge of
DAT0+ from LP-01 (~62 mV) to LP-00 (~0 mV) — the entry into T_HS_PREPARE.
4. **Cache bypass is mandatory** before reading IRQ_STAT (0xE5). Without it you
get the last-written value, not the current hardware state. The server must
do this every time `/sn65_registers` is called.
5. **Unit Interval (UI):** At 72 MHz pixel clock, DSI = 432 Mbps per lane,
UI = 1/432MHz ≈ 2.315 ns. All spec minimums involving UI must be calculated
dynamically if pixel clock is changed via `--pixel-clock` argument.
6. **The DSIM register layout is undocumented** (see TODO in `samsung-dsim.c`).
Log raw hex AND decoded values. Cross-reference against kernel `dmesg` output
which prints the cycle counts explicitly when `dsi-tweak` logging is enabled.

Binary file not shown.

Binary file not shown.

1
analysis/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Pure analysis functions over captured waveforms and register dumps."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

132
analysis/registers.py Normal file
View File

@@ -0,0 +1,132 @@
"""Parse SN65DSI83 and DSIM register dumps into structured flags.
DSIM PHY_TIMING bit-field layout is undocumented in the i.MX 8M Mini RM.
We log raw hex AND decoded cycle counts so they can be cross-checked
against kernel dmesg output that prints the cycle counts explicitly.
"""
from __future__ import annotations
from typing import Optional
from config import (
BYTE_CLK_HZ,
SN65_ERR_SOT,
SN65_ERR_SYNCH,
SN65_ERR_UNC,
SN65_FLICKER_MASK,
)
def _to_int(v) -> Optional[int]:
if v is None:
return None
if isinstance(v, int):
return v
s = str(v).strip().lower()
try:
if s.startswith("0x"):
return int(s, 16)
return int(s, 16)
except ValueError:
return None
# ---------------------------------------------------------------------------
# SN65DSI83
# ---------------------------------------------------------------------------
def parse_sn65(reg_json: dict) -> dict:
"""Extract structured flicker flags from /sn65_registers response.
Accepts either the server's pre-parsed shape (with explicit bool keys)
or a raw {register: hex} mapping; falls back to bit-decoding in either case.
"""
irq_raw = _to_int(reg_json.get("irq_stat_raw"))
if irq_raw is None:
regs = reg_json.get("registers", {})
irq_raw = _to_int(regs.get("e5") or regs.get("E5") or regs.get("0xE5"))
irq_raw = irq_raw or 0
pll_raw = _to_int(reg_json.get("registers", {}).get("0a")) if reg_json.get("registers") else None
clk_raw = _to_int(reg_json.get("registers", {}).get("0b")) if reg_json.get("registers") else None
pll_locked = reg_json.get("pll_locked")
if pll_locked is None and pll_raw is not None:
pll_locked = bool(pll_raw & 0x80)
clk_detected = reg_json.get("clk_detected")
if clk_detected is None and clk_raw is not None:
clk_detected = bool(clk_raw & 0x01)
sot_err = bool(irq_raw & SN65_ERR_SOT)
synch_err = bool(irq_raw & SN65_ERR_SYNCH)
unc_ecc_err = bool(irq_raw & SN65_ERR_UNC)
flicker_detected = bool(irq_raw & SN65_FLICKER_MASK)
return {
"irq_stat_raw": f"0x{irq_raw:02X}",
"irq_stat_int": irq_raw,
"pll_locked": bool(pll_locked) if pll_locked is not None else None,
"clk_detected": bool(clk_detected) if clk_detected is not None else None,
"sot_err": sot_err,
"synch_err": synch_err,
"unc_ecc_err": unc_ecc_err,
"flicker_detected": flicker_detected,
"registers": reg_json.get("registers", {}),
}
# ---------------------------------------------------------------------------
# DSIM PHY_TIMING / PHY_TIMING1 / PHY_TIMING2
# ---------------------------------------------------------------------------
def _cycles_to_ns(cycles: int) -> float:
return cycles / BYTE_CLK_HZ * 1e9
def parse_dsim(reg_json: dict) -> dict:
pt = _to_int(reg_json.get("PHY_TIMING"))
pt1 = _to_int(reg_json.get("PHY_TIMING1"))
pt2 = _to_int(reg_json.get("PHY_TIMING2"))
out: dict = {
"PHY_TIMING_raw": f"0x{pt:08X}" if pt is not None else None,
"PHY_TIMING1_raw": f"0x{pt1:08X}" if pt1 is not None else None,
"PHY_TIMING2_raw": f"0x{pt2:08X}" if pt2 is not None else None,
}
if pt is not None:
hs_exit = (pt >> 4) & 0xF
lpx = pt & 0xF
out["hs_exit_cycles"] = hs_exit
out["hs_exit_ns"] = _cycles_to_ns(hs_exit)
out["lpx_cycles"] = lpx
out["lpx_ns"] = _cycles_to_ns(lpx)
if pt1 is not None:
clk_zero = (pt1 >> 24) & 0xFF
clk_post = (pt1 >> 16) & 0xFF
clk_trail = (pt1 >> 8) & 0xFF
clk_prepare = pt1 & 0xFF
out["clk_zero_cycles"] = clk_zero
out["clk_zero_ns"] = _cycles_to_ns(clk_zero)
out["clk_post_cycles"] = clk_post
out["clk_post_ns"] = _cycles_to_ns(clk_post)
out["clk_trail_cycles"] = clk_trail
out["clk_trail_ns"] = _cycles_to_ns(clk_trail)
out["clk_prepare_cycles"] = clk_prepare
out["clk_prepare_ns"] = _cycles_to_ns(clk_prepare)
if pt2 is not None:
hs_prepare = (pt2 >> 16) & 0xFF
hs_zero = (pt2 >> 8) & 0xFF
hs_trail = pt2 & 0xFF
out["hs_prepare_cycles"] = hs_prepare
out["hs_prepare_ns"] = _cycles_to_ns(hs_prepare)
out["hs_zero_cycles"] = hs_zero
out["hs_zero_ns"] = _cycles_to_ns(hs_zero)
out["hs_trail_cycles"] = hs_trail
out["hs_trail_ns"] = _cycles_to_ns(hs_trail)
return out

172
analysis/report.py Normal file
View File

@@ -0,0 +1,172 @@
"""Per-run artefact writers and the master flicker_log.csv appender."""
from __future__ import annotations
import csv
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
import pandas as pd
from config import CAPTURE_ROOT
FLICKER_LOG_NAME = "flicker_log.csv"
FLICKER_LOG_COLUMNS = [
"run_id",
"timestamp",
"flicker_detected",
"sot_err",
"synch_err",
"pll_locked",
"t_lpx_ns",
"t_hs_prepare_ns",
"t_hs_prepare_pass",
"t_clk_prepare_ns",
"t_clk_zero_ns",
"t_clk_prep_plus_zero_ns",
"t_clk_prep_zero_pass",
"phy_timing_raw",
"phy_timing1_raw",
"phy_timing2_raw",
"notes",
]
def make_run_dir(root: str = CAPTURE_ROOT, run_idx: Optional[int] = None) -> Path:
base = Path(root)
base.mkdir(parents=True, exist_ok=True)
if run_idx is None:
run_idx = _next_run_index(base)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
run_id = f"run_{run_idx:03d}_{stamp}"
path = base / run_id
path.mkdir(parents=True, exist_ok=False)
return path
def _next_run_index(base: Path) -> int:
existing = [p.name for p in base.iterdir() if p.is_dir() and p.name.startswith("run_")]
if not existing:
return 1
nums: list[int] = []
for n in existing:
try:
nums.append(int(n.split("_")[1]))
except (IndexError, ValueError):
continue
return (max(nums) + 1) if nums else 1
def save_waveforms(run_dir: Path, waveforms: dict[str, pd.DataFrame]) -> None:
"""Save each channel as waveform_chN.csv per spec §8.3."""
label_to_ch = {"CLK_P": 1, "CLK_N": 2, "DAT0_P": 3, "DAT0_N": 4}
for label, df in waveforms.items():
ch = label_to_ch.get(label)
if ch is None:
continue
df.to_csv(run_dir / f"waveform_ch{ch}.csv", index=False)
def save_registers(run_dir: Path, dsim: dict, sn65: dict, settling: dict | list) -> None:
payload = {"dsim": dsim, "sn65": sn65, "settling": settling}
(run_dir / "registers.json").write_text(json.dumps(payload, indent=2))
def save_timing_analysis(run_dir: Path, measurements: dict, spec_pass: dict,
packet_fault: dict, lane_stall: dict) -> None:
payload = {
"measurements_ns": measurements,
"spec_compliance": spec_pass,
"packet_fault_a": packet_fault,
"lane_stall_b": lane_stall,
}
(run_dir / "timing_analysis.json").write_text(json.dumps(payload, indent=2))
def save_summary(run_dir: Path, summary_text: str) -> None:
(run_dir / "summary.txt").write_text(summary_text)
def append_flicker_log(root: str, row: dict) -> None:
log_path = Path(root) / FLICKER_LOG_NAME
is_new = not log_path.exists()
with log_path.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FLICKER_LOG_COLUMNS, extrasaction="ignore")
if is_new:
writer.writeheader()
writer.writerow(row)
def build_summary(run_id: str, sn65_parsed: dict, measurements: dict,
spec_pass: dict, packet_fault: dict, lane_stall: dict,
dsim_parsed: dict, note: str = "") -> str:
lines = [
f"Run: {run_id}",
f"Timestamp: {datetime.now().isoformat(timespec='seconds')}",
"",
"[ SN65DSI83 ]",
f" PLL locked: {sn65_parsed.get('pll_locked')}",
f" Clock detect: {sn65_parsed.get('clk_detected')}",
f" IRQ_STAT: {sn65_parsed.get('irq_stat_raw')}",
f" SOT_ERR: {sn65_parsed.get('sot_err')}",
f" SYNCH_ERR: {sn65_parsed.get('synch_err')}",
f" UNC_ECC_ERR: {sn65_parsed.get('unc_ecc_err')}",
f" FLICKER: {sn65_parsed.get('flicker_detected')}",
"",
"[ D-PHY timings (ns) ]",
]
for k, v in measurements.items():
sp = spec_pass.get(k, {})
marker = "OK" if sp.get("pass") else "VIOLATION"
margin = sp.get("margin_ns")
margin_str = f"margin={margin:+.2f}" if margin is not None else "margin=n/a"
v_str = f"{v:.2f}" if v is not None and v == v else "nan" # NaN check
lines.append(f" {k:30s} {v_str:>8s} [{marker}] (min={sp.get('min_ns')}, {margin_str})")
lines += [
"",
"[ Packet decode (Lane 0) ]",
f" Fault A (zero-payload pixel pkt): {packet_fault.get('fault_a_detected')}",
f" First payload bytes: {packet_fault.get('first_pixel_payload_hex')}",
f" Pixel packets / total: "
f"{packet_fault.get('n_pixel_packets')} / {packet_fault.get('n_total_packets')}",
"",
"[ Lane stall ]",
f" Fault B (LP-11 stall): {lane_stall.get('fault_b_detected')}",
f" Longest LP-11 (ms): {lane_stall.get('longest_lp11_ms')}",
"",
"[ DSIM raw / decoded ]",
f" PHY_TIMING: {dsim_parsed.get('PHY_TIMING_raw')}",
f" PHY_TIMING1: {dsim_parsed.get('PHY_TIMING1_raw')}",
f" PHY_TIMING2: {dsim_parsed.get('PHY_TIMING2_raw')}",
]
if note:
lines += ["", f"Note: {note}"]
return os.linesep.join(lines) + os.linesep
def build_log_row(run_id: str, sn65_parsed: dict, measurements: dict,
spec_pass: dict, dsim_parsed: dict, note: str = "") -> dict:
return {
"run_id": run_id,
"timestamp": datetime.now().isoformat(timespec="seconds"),
"flicker_detected": sn65_parsed.get("flicker_detected"),
"sot_err": sn65_parsed.get("sot_err"),
"synch_err": sn65_parsed.get("synch_err"),
"pll_locked": sn65_parsed.get("pll_locked"),
"t_lpx_ns": measurements.get("t_lpx"),
"t_hs_prepare_ns": measurements.get("t_hs_prepare"),
"t_hs_prepare_pass": spec_pass.get("t_hs_prepare", {}).get("pass"),
"t_clk_prepare_ns": measurements.get("t_clk_prepare"),
"t_clk_zero_ns": measurements.get("t_clk_zero"),
"t_clk_prep_plus_zero_ns": measurements.get("t_clk_prepare_plus_zero"),
"t_clk_prep_zero_pass": spec_pass.get("t_clk_prepare_plus_zero", {}).get("pass"),
"phy_timing_raw": dsim_parsed.get("PHY_TIMING_raw"),
"phy_timing1_raw": dsim_parsed.get("PHY_TIMING1_raw"),
"phy_timing2_raw": dsim_parsed.get("PHY_TIMING2_raw"),
"notes": note,
}

401
analysis/waveform.py Normal file
View File

@@ -0,0 +1,401 @@
"""D-PHY timing extraction and Lane 0 packet decode from scope waveforms.
All voltage thresholds in this module are POST-attenuation values (i.e. what
the scope sees after the 19.2× probe divider). Don't rescale them back to
wire voltages — the divider is calibrated and the thresholds were chosen
to give clean LP/HS state separation at probe output.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
import numpy as np
import pandas as pd
from config import DPHY_SPEC
log = logging.getLogger(__name__)
# Post-attenuation thresholds (volts at scope input, after 19.2× divider).
LP_HIGH_V = 0.040 # "above" → LP-1 (~770 mV on wire)
LP_LOW_V = 0.010 # "below" → LP-0 / HS-0 (~190 mV on wire)
HS_DIFF_V = 0.008 # |CLK_P CLK_N| above this means HS burst is active
@dataclass
class LaneStateSpan:
"""A contiguous run of single-ended-detected lane state."""
state: str # "LP-11" | "LP-01" | "LP-10" | "LP-00" | "HS"
t_start: float
t_end: float
@property
def duration_ns(self) -> float:
return (self.t_end - self.t_start) * 1e9
# ---------------------------------------------------------------------------
# Signal reconstruction
# ---------------------------------------------------------------------------
def differential(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> pd.Series:
return pd.Series(lane_p["voltage_v"].values - lane_n["voltage_v"].values)
def common_mode(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> pd.Series:
return pd.Series((lane_p["voltage_v"].values + lane_n["voltage_v"].values) / 2.0)
# ---------------------------------------------------------------------------
# Lane state machine
# ---------------------------------------------------------------------------
def _classify_sample(vp: float, vn: float, vdiff: float) -> str:
"""Classify a single (p, n) sample into a D-PHY lane state."""
if abs(vdiff) > HS_DIFF_V and vp < LP_HIGH_V and vn < LP_HIGH_V:
return "HS"
p_high = vp > LP_HIGH_V
n_high = vn > LP_HIGH_V
p_low = vp < LP_LOW_V
n_low = vn < LP_LOW_V
if p_high and n_high:
return "LP-11"
if p_low and n_high:
return "LP-01"
if p_high and n_low:
return "LP-10"
if p_low and n_low:
return "LP-00"
return "TRANS" # in-between, not yet a settled state
def classify_lane(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> list[LaneStateSpan]:
"""Walk both single-ended traces and emit consecutive state spans.
Spans labelled "TRANS" are dropped — they are sub-sample edge transitions,
not real D-PHY states. Adjacent same-state spans are merged.
"""
t = lane_p["time_s"].values
vp = lane_p["voltage_v"].values
vn = lane_n["voltage_v"].values
vd = vp - vn
spans: list[LaneStateSpan] = []
cur_state: Optional[str] = None
cur_start = t[0]
for i in range(len(t)):
s = _classify_sample(vp[i], vn[i], vd[i])
if s == "TRANS":
continue
if cur_state is None:
cur_state = s
cur_start = t[i]
continue
if s != cur_state:
spans.append(LaneStateSpan(cur_state, cur_start, t[i]))
cur_state = s
cur_start = t[i]
if cur_state is not None:
spans.append(LaneStateSpan(cur_state, cur_start, t[-1]))
return spans
def _first_span(spans: list[LaneStateSpan], state: str,
start_idx: int = 0) -> Optional[tuple[int, LaneStateSpan]]:
for i in range(start_idx, len(spans)):
if spans[i].state == state:
return i, spans[i]
return None
# ---------------------------------------------------------------------------
# Per-parameter measurements
# ---------------------------------------------------------------------------
# Each function returns nanoseconds, or NaN if the relevant state span is not
# present in the capture window.
def measure_t_lpx(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float:
"""Duration of LP-01 (Dp low, Dn high) on data lane — HS Request."""
spans = classify_lane(data_lane_p, data_lane_n)
hit = _first_span(spans, "LP-01")
return hit[1].duration_ns if hit else float("nan")
def measure_t_hs_prepare(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float:
"""Duration of LP-00 on data lane immediately before HS-0 entry."""
spans = classify_lane(data_lane_p, data_lane_n)
for i in range(len(spans) - 1):
if spans[i].state == "LP-00" and spans[i + 1].state == "HS":
return spans[i].duration_ns
return float("nan")
def measure_t_clk_prepare(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float:
"""Duration of LP-00 on clock lane immediately before HS clock starts."""
spans = classify_lane(clk_p, clk_n)
for i in range(len(spans) - 1):
if spans[i].state == "LP-00" and spans[i + 1].state == "HS":
return spans[i].duration_ns
return float("nan")
def measure_t_clk_zero(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float:
"""Duration of HS-0 on clock lane before first clock toggle.
Implementation: find the LP-00 → HS transition, then walk the differential
until the first edge crossing in the opposite polarity (clock toggle).
"""
t = clk_p["time_s"].values
vd = clk_p["voltage_v"].values - clk_n["voltage_v"].values
spans = classify_lane(clk_p, clk_n)
hs_start: Optional[float] = None
for i in range(len(spans) - 1):
if spans[i].state == "LP-00" and spans[i + 1].state == "HS":
hs_start = spans[i + 1].t_start
break
if hs_start is None:
return float("nan")
start_idx = int(np.searchsorted(t, hs_start))
initial = vd[start_idx]
sign = -1 if initial >= 0 else 1 # look for opposite-polarity crossing
for j in range(start_idx + 1, len(vd)):
if (sign > 0 and vd[j] > HS_DIFF_V) or (sign < 0 and vd[j] < -HS_DIFF_V):
return (t[j] - hs_start) * 1e9
return float("nan")
def measure_t_clk_prepare_plus_zero(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float:
a = measure_t_clk_prepare(clk_p, clk_n)
b = measure_t_clk_zero(clk_p, clk_n)
if np.isnan(a) or np.isnan(b):
return float("nan")
return a + b
def measure_t_hs_zero(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float:
"""HS-0 preamble on data lane before SoT sync byte (00011101 = 0xB8 LSB-first).
Approximated as duration from HS entry until first differential transition
(i.e. first clock-edge-aligned bit flip).
"""
t = data_lane_p["time_s"].values
vd = data_lane_p["voltage_v"].values - data_lane_n["voltage_v"].values
spans = classify_lane(data_lane_p, data_lane_n)
hs_start: Optional[float] = None
for i in range(len(spans) - 1):
if spans[i].state == "LP-00" and spans[i + 1].state == "HS":
hs_start = spans[i + 1].t_start
break
if hs_start is None:
return float("nan")
start_idx = int(np.searchsorted(t, hs_start))
initial = vd[start_idx]
sign = -1 if initial >= 0 else 1
for j in range(start_idx + 1, len(vd)):
if (sign > 0 and vd[j] > HS_DIFF_V) or (sign < 0 and vd[j] < -HS_DIFF_V):
return (t[j] - hs_start) * 1e9
return float("nan")
# ---------------------------------------------------------------------------
# Aggregate measurement + spec compliance
# ---------------------------------------------------------------------------
def measure_all(waveforms: dict[str, pd.DataFrame]) -> dict[str, float]:
clk_p = waveforms["CLK_P"]
clk_n = waveforms["CLK_N"]
dat_p = waveforms["DAT0_P"]
dat_n = waveforms["DAT0_N"]
return {
"t_lpx": measure_t_lpx(dat_p, dat_n),
"t_hs_prepare": measure_t_hs_prepare(dat_p, dat_n),
"t_clk_prepare": measure_t_clk_prepare(clk_p, clk_n),
"t_clk_zero": measure_t_clk_zero(clk_p, clk_n),
"t_clk_prepare_plus_zero": measure_t_clk_prepare_plus_zero(clk_p, clk_n),
"t_hs_zero": measure_t_hs_zero(dat_p, dat_n),
}
def check_spec_compliance(measurements: dict[str, float],
spec: dict[str, float] = DPHY_SPEC) -> dict:
out: dict[str, dict] = {}
for name, measured_ns in measurements.items():
min_ns = spec.get(name)
if min_ns is None:
continue
if measured_ns is None or np.isnan(measured_ns):
out[name] = {
"measured_ns": None,
"min_ns": min_ns,
"pass": False,
"margin_ns": None,
}
continue
out[name] = {
"measured_ns": float(measured_ns),
"min_ns": float(min_ns),
"pass": bool(measured_ns >= min_ns),
"margin_ns": float(measured_ns - min_ns),
}
return out
# ---------------------------------------------------------------------------
# Lane 0 DSI packet decode
# ---------------------------------------------------------------------------
# Ground-truth fault detector (Falcon prior art, May 2024). The SN65 IRQ
# register is a hint — packet payload position is the verdict.
DSI_SOT_SYNC = 0xB8 # SoT sync byte after LP-11 → LP-01 → LP-00 → HS-0
DSI_DT_PIXEL = 0x3E # Packed Pixel Stream, 24-bit RGB (long packet)
DSI_DT_HSYNC_START = 0x21
@dataclass
class DSIPacket:
burst_idx: int
timestamp_s: float
data_type: int
word_count: int
ecc: int
payload: bytes
def _find_hs_bursts(clk_p: pd.DataFrame, clk_n: pd.DataFrame,
dat_p: pd.DataFrame, dat_n: pd.DataFrame) -> list[tuple[float, float]]:
"""Return (t_start, t_end) for each HS burst on the data lane."""
spans = classify_lane(dat_p, dat_n)
return [(s.t_start, s.t_end) for s in spans if s.state == "HS"]
def _sample_bits_in_burst(clk_p: pd.DataFrame, clk_n: pd.DataFrame,
dat_p: pd.DataFrame, dat_n: pd.DataFrame,
t_start: float, t_end: float) -> list[int]:
"""DDR-sample the data lane at every clock edge inside the burst window.
Returns a list of 0/1 bit values, in clock-edge order.
"""
t_clk = clk_p["time_s"].values
vd_clk = clk_p["voltage_v"].values - clk_n["voltage_v"].values
t_dat = dat_p["time_s"].values
vd_dat = dat_p["voltage_v"].values - dat_n["voltage_v"].values
i0 = int(np.searchsorted(t_clk, t_start))
i1 = int(np.searchsorted(t_clk, t_end))
if i1 - i0 < 2:
return []
edges: list[float] = []
prev_sign = 1 if vd_clk[i0] >= 0 else -1
for k in range(i0 + 1, i1):
cur_sign = 1 if vd_clk[k] >= 0 else -1
if cur_sign != prev_sign:
edges.append(t_clk[k])
prev_sign = cur_sign
bits: list[int] = []
for et in edges:
idx = int(np.searchsorted(t_dat, et))
if 0 <= idx < len(vd_dat):
bits.append(1 if vd_dat[idx] > 0 else 0)
return bits
def _bits_to_bytes_msb_first(bits: list[int]) -> bytes:
out = bytearray()
for i in range(0, len(bits) - 7, 8):
b = 0
for k in range(8):
b = (b << 1) | (bits[i + k] & 1)
out.append(b)
return bytes(out)
def decode_lane0_packets(waveforms: dict[str, pd.DataFrame],
max_payload_bytes: int = 16) -> list[DSIPacket]:
"""Best-effort DSI Lane 0 packet decode.
Scope window at 5 ns/div × 500 kpts is ~2.5 µs — enough for SoT + header
+ first ~200 bytes of payload. We only need the first few payload bytes
to classify Fault A (all-zero payload start).
"""
clk_p = waveforms["CLK_P"]
clk_n = waveforms["CLK_N"]
dat_p = waveforms["DAT0_P"]
dat_n = waveforms["DAT0_N"]
bursts = _find_hs_bursts(clk_p, clk_n, dat_p, dat_n)
packets: list[DSIPacket] = []
for idx, (t0, t1) in enumerate(bursts):
bits = _sample_bits_in_burst(clk_p, clk_n, dat_p, dat_n, t0, t1)
bs = _bits_to_bytes_msb_first(bits)
sot_pos = bs.find(bytes([DSI_SOT_SYNC]))
if sot_pos < 0 or len(bs) < sot_pos + 5:
continue
header = bs[sot_pos + 1 : sot_pos + 5]
data_type = header[0]
word_count = header[1] | (header[2] << 8)
ecc = header[3]
payload_start = sot_pos + 5
payload_end = min(payload_start + max_payload_bytes, len(bs))
payload = bs[payload_start:payload_end]
packets.append(DSIPacket(
burst_idx=idx,
timestamp_s=t0,
data_type=data_type,
word_count=word_count,
ecc=ecc,
payload=payload,
))
return packets
def classify_packet_fault(packets: list[DSIPacket]) -> dict:
"""Classify Fault A (zero-payload pixel packet) from decoded packets."""
pixel_packets = [p for p in packets if p.data_type == DSI_DT_PIXEL]
if not pixel_packets:
return {"fault_a_detected": False, "reason": "no pixel packets decoded"}
first = pixel_packets[0]
head = first.payload[:8] if first.payload else b""
fault_a = len(head) >= 4 and all(b == 0x00 for b in head[:4])
return {
"fault_a_detected": bool(fault_a),
"first_pixel_payload_hex": head.hex(),
"n_pixel_packets": len(pixel_packets),
"n_total_packets": len(packets),
}
def detect_lane_stall(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame,
stall_threshold_ms: float = 10.0) -> dict:
"""Fault B: continuous LP-11 longer than threshold during what should be active video."""
spans = classify_lane(data_lane_p, data_lane_n)
longest_lp11_ms = 0.0
for s in spans:
if s.state == "LP-11":
ms = s.duration_ns / 1e6
if ms > longest_lp11_ms:
longest_lp11_ms = ms
return {
"fault_b_detected": bool(longest_lp11_ms > stall_threshold_ms),
"longest_lp11_ms": float(longest_lp11_ms),
"threshold_ms": float(stall_threshold_ms),
}

0
captures/.gitkeep Normal file
View File

118
config.py Normal file
View File

@@ -0,0 +1,118 @@
"""Central configuration for the MIPI flicker investigation suite.
All IP addresses, register addresses, MIPI D-PHY spec minimums, and probe
calibration constants live here. This is the single tuning surface — modules
should import from here rather than hard-coding values.
"""
# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------
TARGET_IP = "192.168.45.8"
TARGET_PORT = 5000
SCOPE_IP = "192.168.45.4"
PSU_IP = "192.168.45.3"
# ---------------------------------------------------------------------------
# Scope hardware
# ---------------------------------------------------------------------------
SCOPE_CHANNELS = {
"CLK_P": 1,
"CLK_N": 2,
"DAT0_P": 3,
"DAT0_N": 4,
}
PROBE_ATTENUATION = 19.2
SCOPE_TIMEBASE = 5e-9
SCOPE_POINTS = 500_000
TRIGGER_CHANNEL = 3
TRIGGER_LEVEL_V = 0.05
TRIGGER_SLOPE = "NEGative"
# ---------------------------------------------------------------------------
# PSU
# ---------------------------------------------------------------------------
PSU_CHANNEL_DISPLAY = 1
PSU_DISPLAY_VOLTAGE = 3.3
PSU_DISPLAY_CURRENT = 1.0
PSU_POWER_CYCLE_DELAY_S = 2.0
# ---------------------------------------------------------------------------
# Pixel clock & DSI parameters
# ---------------------------------------------------------------------------
PIXEL_CLOCK_HZ = 72_000_000
DSI_LANES = 4
BITS_PER_PIXEL = 24
def derive_clocks(pixel_clock_hz: int) -> dict:
"""Recompute DSI/byte clock and UI for a given pixel clock.
Used when --pixel-clock overrides the default — UI feeds into several
DPHY_SPEC minimums, so they must be recomputed from the live value.
"""
dsi_clk_hz = pixel_clock_hz * BITS_PER_PIXEL // DSI_LANES
byte_clk_hz = dsi_clk_hz // 8
ui_ns = 1e9 / dsi_clk_hz
return {
"DSI_CLK_HZ": dsi_clk_hz,
"BYTE_CLK_HZ": byte_clk_hz,
"UI_NS": ui_ns,
}
_clocks = derive_clocks(PIXEL_CLOCK_HZ)
DSI_CLK_HZ = _clocks["DSI_CLK_HZ"]
BYTE_CLK_HZ = _clocks["BYTE_CLK_HZ"]
UI_NS = _clocks["UI_NS"]
def build_dphy_spec(ui_ns: float) -> dict:
"""Build the MIPI D-PHY v1.1 minimum-timing dict for a given UI."""
return {
"t_lpx": 50.0,
"t_clk_prepare": 38.0,
"t_clk_zero": 262.0,
"t_clk_prepare_plus_zero": 300.0,
"t_clk_trail": 60.0,
"t_clk_post": 60.0 + 52 * ui_ns,
"t_hs_prepare": 40.0 + 4 * ui_ns,
"t_hs_zero": 145.0 + 10 * ui_ns,
"t_hs_trail": max(8 * ui_ns, 60.0 + 4 * ui_ns),
"t_hs_exit": 100.0,
}
DPHY_SPEC = build_dphy_spec(UI_NS)
# ---------------------------------------------------------------------------
# SN65DSI83 I2C
# ---------------------------------------------------------------------------
SN65_I2C_ADDR = 0x2C
SN65_I2C_BUS = 4 # Spec §11 says 2; live hardware shows the bridge on bus 4
SN65_REG_IRQ = 0xE5
SN65_REG_PLL = 0x0A
SN65_REG_CLK = 0x0B
SN65_ERR_SYNCH = 1 << 3
SN65_ERR_SOT = 1 << 4
SN65_ERR_UNC = 1 << 6
SN65_FLICKER_MASK = SN65_ERR_SYNCH | SN65_ERR_SOT | SN65_ERR_UNC
# ---------------------------------------------------------------------------
# DSIM PHY timing registers (i.MX8M Mini, samsung-dsim)
# ---------------------------------------------------------------------------
DSIM_PHYTIMING_BASE = 0x32E100B4
DSIM_PHYTIMING1 = 0x32E100B8
DSIM_PHYTIMING2 = 0x32E100BC
# ---------------------------------------------------------------------------
# U-boot dsi-tweak bitmask (reference — not written from here)
# ---------------------------------------------------------------------------
TWEAK_BIT_FIFO_FLUSH = 1 << 0
TWEAK_BIT_ROUND_UP = 1 << 2
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
CAPTURE_ROOT = "captures"

7
hardware/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Instrument I/O — VXI-11 to scope/PSU and HTTP REST to the i.MX target."""
from hardware.psu import PSUController
from hardware.scope import ScopeController
from hardware.target import TargetController
__all__ = ["PSUController", "ScopeController", "TargetController"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

58
hardware/psu.py Normal file
View File

@@ -0,0 +1,58 @@
"""Siglent SPD3303X-E PSU controller over VXI-11 / SCPI.
Drives the display 3.3 V rail so the master loop can power-cycle the PCB
between captures.
"""
from __future__ import annotations
import logging
import time
import vxi11
from config import (
PSU_CHANNEL_DISPLAY,
PSU_DISPLAY_CURRENT,
PSU_DISPLAY_VOLTAGE,
PSU_POWER_CYCLE_DELAY_S,
)
log = logging.getLogger(__name__)
class PSUController:
def __init__(self, ip: str) -> None:
self.ip = ip
self._inst = vxi11.Instrument(ip)
idn = self._inst.ask("*IDN?").strip()
log.info("PSU connected: %s", idn)
self.idn = idn
ch = PSU_CHANNEL_DISPLAY
self._inst.write(f"CH{ch}:VOLTage {PSU_DISPLAY_VOLTAGE}")
self._inst.write(f"CH{ch}:CURRent {PSU_DISPLAY_CURRENT}")
self.output_off()
def output_on(self) -> None:
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},ON")
def output_off(self) -> None:
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},OFF")
def power_cycle(self, delay_s: float = PSU_POWER_CYCLE_DELAY_S) -> None:
self.output_off()
time.sleep(delay_s)
self.output_on()
def measure(self) -> dict:
ch = PSU_CHANNEL_DISPLAY
voltage = float(self._inst.ask(f"MEASure:VOLTage? CH{ch}"))
current = float(self._inst.ask(f"MEASure:CURRent? CH{ch}"))
return {"voltage_v": voltage, "current_a": current}
def close(self) -> None:
try:
self._inst.close()
except Exception:
pass

133
hardware/scope.py Normal file
View File

@@ -0,0 +1,133 @@
"""Keysight DSO80204B scope controller over VXI-11 / SCPI.
Configures channels for MIPI D-PHY probing (50 Ω DC, 19.2× attenuation),
arms a single trigger, and downloads ASCII waveforms with absolute
timestamps reconstructed from the preamble.
CRITICAL: All four channels must be DC50 — the 910R+50R divider only gives
the documented 19.2× ratio with 50 Ω termination.
"""
from __future__ import annotations
import io
import logging
import time
import pandas as pd
import vxi11
from config import (
PROBE_ATTENUATION,
SCOPE_CHANNELS,
SCOPE_POINTS,
SCOPE_TIMEBASE,
TRIGGER_CHANNEL,
TRIGGER_LEVEL_V,
TRIGGER_SLOPE,
)
log = logging.getLogger(__name__)
class ScopeController:
def __init__(self, ip: str) -> None:
self.ip = ip
self._inst = vxi11.Instrument(ip)
idn = self._inst.ask("*IDN?").strip()
log.info("Scope connected: %s", idn)
self.idn = idn
def setup(self) -> None:
i = self._inst
i.write("*RST")
time.sleep(1.0)
i.write(":STOP")
for label, ch in SCOPE_CHANNELS.items():
i.write(f":CHANnel{ch}:DISPlay ON")
i.write(f":CHANnel{ch}:INPut DC50")
i.write(f":CHANnel{ch}:PROBe {PROBE_ATTENUATION}")
i.write(f":CHANnel{ch}:SCALe 0.05")
i.write(f":CHANnel{ch}:OFFSet 0.0")
i.write(f":CHANnel{ch}:LABel '{label}'")
i.write(f":TIMebase:SCALe {SCOPE_TIMEBASE:.3E}")
i.write(":TIMebase:POSition 0")
i.write(":TIMebase:REFerence CENTer")
i.write(":TRIGger:MODE EDGE")
i.write(f":TRIGger:EDGE:SOURce CHANnel{TRIGGER_CHANNEL}")
i.write(f":TRIGger:EDGE:SLOPe {TRIGGER_SLOPE}")
i.write(f":TRIGger:EDGE:LEVel {TRIGGER_LEVEL_V}")
i.write(":TRIGger:SWEep NORMal")
i.write(":ACQuire:MODE RTIMe")
i.write(":ACQuire:INTerpolate ON")
i.write(f":ACQuire:POINts {SCOPE_POINTS}")
i.write(":DISPlay:LAYout STACKed")
def arm_single(self) -> None:
self._inst.write(":SINGle")
def wait_for_trigger(self, timeout_s: float = 30.0) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
ter = int(self._inst.ask(":TER?").strip())
except ValueError:
ter = 0
if ter == 1:
return True
time.sleep(0.1)
return False
def _read_preamble(self, channel: int) -> dict:
self._inst.write(f":WAVeform:SOURce CHANnel{channel}")
raw = self._inst.ask(":WAVeform:PREamble?").strip()
parts = raw.split(",")
return {
"format": int(parts[0]),
"type": int(parts[1]),
"points": int(parts[2]),
"count": int(parts[3]),
"x_increment": float(parts[4]),
"x_origin": float(parts[5]),
"x_reference": float(parts[6]),
"y_increment": float(parts[7]),
"y_origin": float(parts[8]),
"y_reference": float(parts[9]),
}
def download_waveform(self, channel: int) -> pd.DataFrame:
preamble = self._read_preamble(channel)
self._inst.write(":WAVeform:FORMat ASCii")
self._inst.write(":WAVeform:STReaming ON")
self._inst.write(f":WAVeform:SOURce CHANnel{channel}")
raw = self._inst.ask(":WAVeform:DATA?")
if raw.startswith("#"):
n_digits = int(raw[1])
raw = raw[2 + n_digits :]
voltages = pd.read_csv(io.StringIO(raw), header=None).iloc[0].astype(float).values
n = len(voltages)
x_inc = preamble["x_increment"]
x_origin = preamble["x_origin"]
times = x_origin + x_inc * pd.Series(range(n), dtype="float64")
return pd.DataFrame({"time_s": times, "voltage_v": voltages})
def download_all(self) -> dict[str, pd.DataFrame]:
return {
label: self.download_waveform(ch) for label, ch in SCOPE_CHANNELS.items()
}
def close(self) -> None:
try:
self._inst.close()
except Exception:
pass

53
hardware/target.py Normal file
View File

@@ -0,0 +1,53 @@
"""HTTP REST client for the i.MX 8M Mini target.
Talks to the Flask server in `server/app.py`. The target must run that server
with appropriate privileges to access /sys/kernel/debug, memtool, and i2c-2.
"""
from __future__ import annotations
import logging
import requests
log = logging.getLogger(__name__)
class TargetController:
def __init__(self, ip: str, port: int, timeout_s: float = 10.0) -> None:
self.base_url = f"http://{ip}:{port}"
self.timeout = timeout_s
probe = requests.get(f"{self.base_url}/registers", timeout=timeout_s)
probe.raise_for_status()
log.info("Target reachable at %s", self.base_url)
def _get(self, path: str) -> dict:
r = requests.get(f"{self.base_url}{path}", timeout=self.timeout)
r.raise_for_status()
return r.json()
def _put(self, path: str, payload: dict) -> dict:
r = requests.put(f"{self.base_url}{path}", json=payload, timeout=self.timeout)
r.raise_for_status()
return r.json()
def get_dsim_registers(self) -> dict:
return self._get("/registers")
def get_sn65_registers(self) -> dict:
return self._get("/sn65_registers")
def get_sn65_settling(self) -> dict:
return self._get("/sn65_settling")
def display_on(self) -> dict:
return self._put("/display", {"state": "on"})
def display_off(self) -> dict:
return self._put("/display", {"state": "off"})
def video_start(self, mode: str = "static-pink") -> dict:
return self._put("/video", {"action": "start", "mode": mode})
def video_stop(self) -> dict:
return self._put("/video", {"action": "stop"})

205
master_loop.py Normal file
View File

@@ -0,0 +1,205 @@
"""Main entry point — the flicker hunt loop.
Runs on the host PC. Cycles power, arms the scope, restarts video, captures
4-channel waveforms + bridge/SoC registers, decodes timings + Lane 0 packets,
appends a row to flicker_log.csv. Repeats until KeyboardInterrupt or
--max-runs is hit.
If --pixel-clock differs from the default, DPHY_SPEC must be rebuilt for the
new UI before checking compliance.
"""
from __future__ import annotations
import argparse
import logging
import math
import sys
import time
from typing import Optional
import config
from analysis import registers as reg_analysis
from analysis import report
from analysis import waveform as wave_analysis
from hardware import PSUController, ScopeController, TargetController
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger("master_loop")
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
p = argparse.ArgumentParser(description="MIPI flicker hunt loop")
p.add_argument("--max-runs", type=int, default=None)
p.add_argument("--timeout", type=float, default=30.0,
help="Scope trigger timeout per run (s)")
p.add_argument("--pixel-clock", type=int, default=config.PIXEL_CLOCK_HZ,
help="Override pixel clock in Hz")
p.add_argument("--note", type=str, default="",
help="Note appended to every log row (e.g. 'dsi-tweak=5')")
p.add_argument("--no-video", action="store_true",
help="Skip PUT /video — display blank/unblank only")
p.add_argument("--output-dir", type=str, default=config.CAPTURE_ROOT)
return p.parse_args(argv)
def _maybe_rebuild_spec(pixel_clock_hz: int) -> dict:
"""Recompute UI-dependent spec minimums if pixel clock was overridden."""
if pixel_clock_hz == config.PIXEL_CLOCK_HZ:
return config.DPHY_SPEC
derived = config.derive_clocks(pixel_clock_hz)
log.info("Rebuilding DPHY_SPEC for %.3f MHz pixel clock (UI=%.3f ns)",
pixel_clock_hz / 1e6, derived["UI_NS"])
return config.build_dphy_spec(derived["UI_NS"])
def run_one(idx: int, args: argparse.Namespace, spec: dict,
psu: PSUController, scope: ScopeController,
target: TargetController) -> None:
log.info("=== run %03d ===", idx)
# RESET
try:
target.display_off()
except Exception as e:
log.warning("display_off failed: %s", e)
if not args.no_video:
try:
target.video_stop()
except Exception as e:
log.warning("video_stop failed: %s", e)
psu.output_off()
time.sleep(config.PSU_POWER_CYCLE_DELAY_S)
# ARM
scope.arm_single()
run_dir = report.make_run_dir(root=args.output_dir, run_idx=idx)
log.info("run dir: %s", run_dir)
# STIMULUS
psu.output_on()
time.sleep(0.5)
target.display_on()
if not args.no_video:
target.video_start(mode="static-pink")
# ACQUIRE
triggered = scope.wait_for_trigger(timeout_s=args.timeout)
if not triggered:
log.warning("TIMEOUT waiting for scope trigger (run %03d)", idx)
report.save_summary(run_dir, f"run_{idx:03d}: TIMEOUT — no scope trigger\n")
return
# CAPTURE
waveforms = scope.download_all()
sn65_data = target.get_sn65_registers()
dsim_data = target.get_dsim_registers()
settling = target.get_sn65_settling()
# ANALYSIS
measurements = wave_analysis.measure_all(waveforms)
spec_pass = wave_analysis.check_spec_compliance(measurements, spec)
sn65_parsed = reg_analysis.parse_sn65(sn65_data)
dsim_parsed = reg_analysis.parse_dsim(dsim_data)
packets = wave_analysis.decode_lane0_packets(waveforms)
packet_fault = wave_analysis.classify_packet_fault(packets)
lane_stall = wave_analysis.detect_lane_stall(waveforms["DAT0_P"], waveforms["DAT0_N"])
flicker_detected = (
sn65_parsed.get("flicker_detected")
or packet_fault.get("fault_a_detected")
or lane_stall.get("fault_b_detected")
)
if flicker_detected:
log.warning("*** FLICKER EVENT CAPTURED (run %03d) ***", idx)
# SAVE
report.save_waveforms(run_dir, waveforms)
report.save_registers(run_dir, dsim_parsed, sn65_parsed, settling)
report.save_timing_analysis(run_dir, measurements, spec_pass, packet_fault, lane_stall)
summary = report.build_summary(
run_id=run_dir.name,
sn65_parsed=sn65_parsed,
measurements=measurements,
spec_pass=spec_pass,
packet_fault=packet_fault,
lane_stall=lane_stall,
dsim_parsed=dsim_parsed,
note=args.note,
)
report.save_summary(run_dir, summary)
log_row = report.build_log_row(
run_id=run_dir.name,
sn65_parsed=sn65_parsed,
measurements=measurements,
spec_pass=spec_pass,
dsim_parsed=dsim_parsed,
note=args.note,
)
report.append_flicker_log(args.output_dir, log_row)
# REPORT
hsp = measurements.get("t_hs_prepare")
cpz = measurements.get("t_clk_prepare_plus_zero")
log.info(
"run %03d: flicker=%s | t_hs_prepare=%s ns | t_clk_prep+zero=%s ns",
idx,
"YES" if flicker_detected else "no",
f"{hsp:.1f}" if hsp is not None and not math.isnan(hsp) else "nan",
f"{cpz:.1f}" if cpz is not None and not math.isnan(cpz) else "nan",
)
def main(argv: Optional[list[str]] = None) -> int:
args = parse_args(argv)
spec = _maybe_rebuild_spec(args.pixel_clock)
log.info("Connecting instruments...")
psu = PSUController(config.PSU_IP)
scope = ScopeController(config.SCOPE_IP)
target = TargetController(config.TARGET_IP, config.TARGET_PORT)
try:
log.info("Configuring scope...")
scope.setup()
psu.output_off()
try:
target.display_off()
if not args.no_video:
target.video_stop()
except Exception as e:
log.warning("Initial reset failed: %s", e)
idx = 0
while True:
idx += 1
try:
run_one(idx, args, spec, psu, scope, target)
except KeyboardInterrupt:
raise
except Exception as e:
log.exception("run %03d failed: %s", idx, e)
if args.max_runs is not None and idx >= args.max_runs:
log.info("Reached --max-runs=%d, stopping", args.max_runs)
break
except KeyboardInterrupt:
log.info("Interrupted by user")
finally:
try:
psu.output_off()
except Exception:
pass
scope.close()
psu.close()
return 0
if __name__ == "__main__":
sys.exit(main())

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
python-vxi11>=0.9
requests>=2.28
flask>=3.0
numpy>=1.24
pandas>=2.0
matplotlib>=3.7

0
server/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

76
server/app.py Normal file
View File

@@ -0,0 +1,76 @@
"""Flask REST server — runs ON THE i.MX 8M Mini target, NOT the host PC.
Endpoints (all rooted at http://<target>:5000):
GET /registers DSIM PHY_TIMING dump via memtool
GET /sn65_registers SN65DSI83 regmap with cache bypass (mandatory)
GET /sn65_settling 2 s register poll @ 100 ms cadence
PUT /display {state: on|off}
PUT /video {action: start|stop, mode: static-pink}
"""
from __future__ import annotations
import logging
from flask import Flask, jsonify, request
try:
from server import hw_interface as hw
except ImportError:
import hw_interface as hw # flat-layout deployment (target /home/root)
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
app = Flask(__name__)
@app.errorhandler(Exception)
def _on_error(e): # noqa: ANN001
log.exception("Request failed: %s", e)
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/registers")
def get_registers():
return jsonify(hw.read_dsim_phy_timing())
@app.get("/sn65_registers")
def get_sn65_registers():
return jsonify(hw.read_sn65_registers())
@app.get("/sn65_settling")
def get_sn65_settling():
return jsonify({"snapshots": hw.settling_capture()})
@app.put("/display")
def put_display():
body = request.get_json(force=True) or {}
state = body.get("state")
if state == "on":
hw.display_on()
elif state == "off":
hw.display_off()
else:
return jsonify({"ok": False, "error": "state must be 'on' or 'off'"}), 400
return jsonify({"ok": True})
@app.put("/video")
def put_video():
body = request.get_json(force=True) or {}
action = body.get("action")
if action == "start":
hw.video_start(mode=body.get("mode", "static-pink"))
elif action == "stop":
hw.video_stop()
else:
return jsonify({"ok": False, "error": "action must be 'start' or 'stop'"}), 400
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)

215
server/hw_interface.py Normal file
View File

@@ -0,0 +1,215 @@
"""On-target hardware shims used by the Flask app.
Runs ON THE i.MX 8M Mini, not the host PC. Shells out to memtool, i2cget,
and /sys/kernel/debug/regmap. Cache bypass for SN65DSI83 is mandatory
before every IRQ_STAT read — see CLAUDE.md invariant 1.
"""
from __future__ import annotations
import logging
import re
import shlex
import signal
import subprocess
import time
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
SN65_REGMAP_DIR = "/sys/kernel/debug/regmap/4-002c"
SN65_I2C_BUS = 4
SN65_I2C_ADDR = 0x2C
DSIM_PHYTIMING_BASE = 0x32E100B4
DSIM_PHYTIMING_LEN = 0x0C
FB_BLANK_PATH = "/sys/class/graphics/fb0/blank"
# Held while a video pipeline is running so PUT /video stop can kill it.
_video_proc: Optional[subprocess.Popen] = None
# ---------------------------------------------------------------------------
# Process helpers
# ---------------------------------------------------------------------------
def _run(cmd: str, check: bool = True, timeout: float = 5.0) -> str:
log.debug("run: %s", cmd)
res = subprocess.run(
shlex.split(cmd),
capture_output=True,
text=True,
timeout=timeout,
)
if check and res.returncode != 0:
raise RuntimeError(
f"Command failed: {cmd}\nstderr: {res.stderr.strip()}"
)
return res.stdout
def _write_sysfs(path: str, value: str) -> None:
Path(path).write_text(value)
# ---------------------------------------------------------------------------
# DSIM PHY_TIMING registers
# ---------------------------------------------------------------------------
def read_dsim_phy_timing() -> dict:
"""memtool md -l 0x32e100b4+0x0c → 3 little-endian 32-bit words.
memtool prints lines like '0x32e100b4: 00000306 03120a04 00040707'
we strip the address prefix (everything up to and including the colon)
before extracting hex words to avoid matching the address itself.
"""
out = _run(f"memtool md -l 0x{DSIM_PHYTIMING_BASE:x}+0x{DSIM_PHYTIMING_LEN:x}")
hex_words: list[str] = []
for line in out.splitlines():
if ":" in line:
line = line.split(":", 1)[1]
hex_words.extend(re.findall(r"\b([0-9a-fA-F]{8})\b", line))
if len(hex_words) < 3:
raise RuntimeError(f"memtool returned unexpected output:\n{out}")
pt, pt1, pt2 = hex_words[:3]
return {
"PHY_TIMING": f"0x{pt}",
"PHY_TIMING1": f"0x{pt1}",
"PHY_TIMING2": f"0x{pt2}",
"raw_hex": f"{pt} {pt1} {pt2}",
}
# ---------------------------------------------------------------------------
# SN65DSI83 register map — regmap-bypassed
# ---------------------------------------------------------------------------
def _bypass_sn65_regmap_cache() -> None:
bypass = Path(SN65_REGMAP_DIR) / "cache_bypass"
try:
bypass.write_text("1\n")
except (FileNotFoundError, PermissionError) as e:
log.warning("Could not bypass regmap cache (%s); falling back to i2cget", e)
def _read_sn65_regmap() -> dict[str, str]:
"""Read /sys/kernel/debug/regmap/2-002c/registers — returns {hex_addr: hex_val}."""
regs_path = Path(SN65_REGMAP_DIR) / "registers"
text = regs_path.read_text()
out: dict[str, str] = {}
for line in text.splitlines():
m = re.match(r"\s*([0-9a-fA-F]+)\s*:?\s*([0-9a-fA-F]+)", line.strip())
if m:
out[m.group(1).lower().lstrip("0").rjust(2, "0")] = m.group(2).lower()
return out
def _read_sn65_via_i2cget(reg: int) -> int:
out = _run(f"i2cget -y -f {SN65_I2C_BUS} 0x{SN65_I2C_ADDR:02x} 0x{reg:02x}")
return int(out.strip(), 16)
def read_sn65_registers() -> dict:
"""Cache-bypassed SN65DSI83 register read with explicit IRQ flags decoded.
Critical: the bypass write must happen on every call — without it,
IRQ_STAT (0xE5) returns the last cached value, not the current hardware
state, and flicker events become invisible.
"""
_bypass_sn65_regmap_cache()
try:
regs = _read_sn65_regmap()
irq_raw = int(regs.get("e5", "0"), 16)
pll_raw = int(regs.get("0a", "0"), 16)
clk_raw = int(regs.get("0b", "0"), 16)
except FileNotFoundError:
regs = {}
irq_raw = _read_sn65_via_i2cget(0xE5)
pll_raw = _read_sn65_via_i2cget(0x0A)
clk_raw = _read_sn65_via_i2cget(0x0B)
regs = {
"e5": f"{irq_raw:02x}",
"0a": f"{pll_raw:02x}",
"0b": f"{clk_raw:02x}",
}
return {
"registers": regs,
"pll_locked": bool(pll_raw & 0x80),
"clk_detected": bool(clk_raw & 0x01),
"irq_stat_raw": f"0x{irq_raw:02X}",
"sot_err": bool(irq_raw & (1 << 4)),
"synch_err": bool(irq_raw & (1 << 3)),
"unc_ecc_err": bool(irq_raw & (1 << 6)),
}
def settling_capture(duration_s: float = 2.0, interval_s: float = 0.1) -> list[dict]:
"""Sample SN65 registers at fixed cadence — catches transient LP→HS errors."""
snapshots: list[dict] = []
deadline = time.monotonic() + duration_s
while time.monotonic() < deadline:
snap = read_sn65_registers()
snap["t_s"] = time.monotonic()
snapshots.append(snap)
time.sleep(interval_s)
return snapshots
# ---------------------------------------------------------------------------
# Display / video control
# ---------------------------------------------------------------------------
def display_on() -> None:
_write_sysfs(FB_BLANK_PATH, "0\n")
def display_off() -> None:
_write_sysfs(FB_BLANK_PATH, "4\n")
_VIDEO_PIPELINES = {
"static-pink": (
"gst-launch-1.0 videotestsrc pattern=solid-color foreground-color=0xFFFF69B4 "
"! video/x-raw,width=1280,height=800,framerate=60/1 "
"! fbdevsink device=/dev/fb0"
),
}
def video_start(mode: str = "static-pink") -> None:
global _video_proc
if _video_proc is not None and _video_proc.poll() is None:
video_stop()
pipeline = _VIDEO_PIPELINES.get(mode)
if pipeline is None:
raise ValueError(f"Unknown video mode: {mode}")
_video_proc = subprocess.Popen(
shlex.split(pipeline),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def video_stop() -> None:
global _video_proc
if _video_proc is None:
return
if _video_proc.poll() is None:
try:
import os
os.killpg(os.getpgid(_video_proc.pid), signal.SIGTERM)
_video_proc.wait(timeout=2.0)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
_video_proc.kill()
except ProcessLookupError:
pass
_video_proc = None