From d0e23c4e01823c2b31015ba281a066782acecd09 Mon Sep 17 00:00:00 2001 From: david rice Date: Wed, 8 Apr 2026 12:55:34 +0100 Subject: [PATCH] Added ai stuff --- __pycache__/csv_preprocessor.cpython-312.pyc | Bin 0 -> 27353 bytes analyze_captures.py | 161 ++++++ csv_preprocessor.py | 554 +++++++++++++++++++ mipi_test.py | 64 +++ 4 files changed, 779 insertions(+) create mode 100644 __pycache__/csv_preprocessor.cpython-312.pyc create mode 100644 analyze_captures.py create mode 100644 csv_preprocessor.py diff --git a/__pycache__/csv_preprocessor.cpython-312.pyc b/__pycache__/csv_preprocessor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bb0973dede6636be641df9cc740c3e09ba5997a GIT binary patch literal 27353 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 P9~EXEXi tuple[str, list[ChannelMetrics]]: + """ + Run the pre-processor on all CSV files for one capture. + Returns (text_summary, list_of_metrics). + Missing files produce a one-line note instead of crashing. + """ + lines = [f"=== Capture {num:04d} {ts} ==="] + metrics_list: list[ChannelMetrics | LPMetrics] = [] + + for key in ("proto_clk", "proto_dat", "sig_clk", "sig_dat", "lp_clk", "lp_dat"): + if key not in files: + lines.append(f" [{key}] MISSING") + continue + try: + if key.startswith("lp_"): + m = analyze_lp_file(files[key]) + else: + m = analyze_file(files[key]) + lines.append(m.summary()) + metrics_list.append(m) + if verbose: + print(m.summary()) + except Exception as exc: + lines.append(f" [{key}] ERROR: {exc}") + + return "\n".join(lines), metrics_list + + +def build_prompt(all_summaries: list[str]) -> str: + body = "\n\n".join(all_summaries) + return ( + "Below are pre-processed summaries of MIPI D-PHY captures. " + "Each capture has three passes per lane (CLK and DAT0):\n" + " sig — high-res HS differential (rise/fall times)\n" + " proto — long-window HS differential (jitter, clock freq, amplitude)\n" + " lp — single-ended LP state capture (LP-11 voltage, SoT sequence, HS bursts)\n\n" + f"{body}\n\n" + "Please:\n" + "1. Identify any consistent spec concerns (HS voltage, LP-11 voltage, LP-low timing).\n" + "2. Highlight any trends over captures (amplitude drift, jitter, LP-11 voltage, etc.).\n" + "3. Flag anomalies — missing LP transitions, short LP-low, unexpected burst counts.\n" + "4. Summarise overall signal health in 2–3 sentences." + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Analyse MIPI CSV captures with Claude") + parser.add_argument("--last", type=int, default=None, metavar="N", + help="Process only the N most recent captures") + parser.add_argument("--capture", type=str, default=None, metavar="NUM", + help="Process a single capture number (e.g. 0001)") + parser.add_argument("--verbose", action="store_true", + help="Print per-file summaries to stdout") + parser.add_argument("--dry-run", action="store_true", + help="Print summaries and prompt but do not call Claude API") + args = parser.parse_args() + + # --- Discover and filter captures --- + groups = group_captures(DATA_DIR) + if not groups: + print(f"No CSV files found in {DATA_DIR}", file=sys.stderr) + sys.exit(1) + + keys = sorted(groups.keys()) # sorted by (timestamp, capture_num) + + if args.capture is not None: + target_num = int(args.capture) + keys = [k for k in keys if k[1] == target_num] + if not keys: + print(f"Capture {args.capture} not found.", file=sys.stderr) + sys.exit(1) + + if args.last is not None: + keys = keys[-args.last:] + + print(f"Processing {len(keys)} capture(s) from {DATA_DIR}\n") + + # --- Run pre-processor --- + all_summaries: list[str] = [] + for ts, num in keys: + summary_text, _ = process_capture(ts, num, groups[(ts, num)], verbose=args.verbose) + all_summaries.append(summary_text) + if not args.verbose: + print(f" Processed capture {num:04d} {ts}") + + # --- Build Claude prompt --- + prompt = build_prompt(all_summaries) + + if args.dry_run: + print("\n--- Prompt that would be sent to Claude ---") + print(prompt) + return + + # --- Call Claude API --- + print(f"\nSending {len(prompt):,} characters to {CLAUDE_MODEL}...\n") + client = anthropic.Anthropic() + message = client.messages.create( + model = CLAUDE_MODEL, + max_tokens = 1024, + system = SYSTEM_PROMPT, + messages = [{"role": "user", "content": prompt}], + ) + analysis = message.content[0].text + + print("=" * 60) + print("CLAUDE ANALYSIS") + print("=" * 60) + print(analysis) + print() + print(f"(Tokens used: {message.usage.input_tokens} in / {message.usage.output_tokens} out)") + + +if __name__ == "__main__": + main() diff --git a/csv_preprocessor.py b/csv_preprocessor.py new file mode 100644 index 0000000..ee2de8b --- /dev/null +++ b/csv_preprocessor.py @@ -0,0 +1,554 @@ +""" +csv_preprocessor.py + +Extracts MIPI HS-TX / LP state metrics from oscilloscope CSV files. + +File naming convention: YYYYMMDD_HHMMSS_{sig|proto|lp}_{NNNN}_{clk|dat}.csv + + sig — high-res short window (320 GSa/s, ~20 ns) — rise/fall times + Two columns: time_s, vdiff_v (F1/F2 differential, ±250 mV HS swing) + proto — lower-res long window (20 GSa/s, ~10 µs) — jitter, frequency, amplitude + Two columns: time_s, vdiff_v (F1/F2 differential) + lp — LP state capture (~40 GSa/s, ~5 µs) — LP-11/LP-00/HS burst structure + Two columns: time_s, voltage_v (Ch1 or Ch3 single-ended CLK+/DAT0+) + Vertical range: −0.2 V to 1.4 V so LP-11 (~1.2 V) and LP-00 (~0 V) are visible. + Trigger: falling edge at 0.6 V on CLK+ catches LP-11 → LP-01 SoT transition. +""" + +import csv +import re +import numpy as np +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +# MIPI D-PHY HS-TX spec limits +HS_VDIFF_MIN_MV = 140.0 # |Vdiff| minimum (mV) +HS_VDIFF_MAX_MV = 270.0 # |Vdiff| maximum (mV) +RISE_FALL_MAX_PS = 500.0 # rise/fall time limit 20%–80% (ps) + +# Thresholds for "settled" vs "transitioning" +TRANSITION_BAND_MV = 50.0 # |Vdiff| < this is considered a transition, not settled + +# MIPI D-PHY LP state thresholds (single-ended voltage, after probe compensation) +LP11_HIGH_V = 0.8 # V — single-ended voltage above this → LP-11 (both pins high ~1.2 V) +LP_LOW_V = 0.05 # V — single-ended voltage below this → LP-00 or LP-01 pin low +LP11_SPEC_MIN_V = 1.0 # V — LP-11 minimum voltage spec +LP11_SPEC_MAX_V = 1.45 # V — LP-11 maximum voltage spec +LP_LOW_DUR_MIN_NS = 50.0 # ns — minimum LP-low duration per D-PHY spec (LP-01 + LP-00 combined) +HS_OSC_STD_V = 0.045 # V — rolling-std threshold above which a region is classified as HS + + +@dataclass +class ChannelMetrics: + timestamp: str + capture_num: int + file_type: str # "sig" | "proto" + channel: str # "clk" | "dat" + + sample_rate_gsps: float + duration_ns: float + n_samples: int + + # HS-TX differential voltage + vdiff_pos_mv: float # mean settled positive level (HS "1") + vdiff_neg_mv: float # mean settled negative level (HS "0") + vdiff_amplitude_mv: float # (|pos| + |neg|) / 2 — spec: 140–270 mV + vcm_mv: float # (pos + neg) / 2 — common-mode offset + + # Timing (None when there are too few transitions to measure) + clock_freq_mhz: Optional[float] = None + jitter_pp_ps: Optional[float] = None + jitter_rms_ps: Optional[float] = None + rise_time_ps: Optional[float] = None + fall_time_ps: Optional[float] = None + n_transitions: int = 0 + + # Spec violations + spec_violations: int = 0 # settled samples where |Vdiff| < HS_VDIFF_MIN_MV + + warnings: list = field(default_factory=list) + + def summary(self) -> str: + ok = lambda cond: "✓" if cond else "✗" + lines = [ + f"Capture {self.capture_num:04d} {self.timestamp} [{self.file_type}/{self.channel}]", + f" Vdiff amplitude : {self.vdiff_amplitude_mv:6.1f} mV " + f"(spec 140–270 mV) {ok(HS_VDIFF_MIN_MV <= self.vdiff_amplitude_mv <= HS_VDIFF_MAX_MV)}", + f" Vdiff pos/neg : +{self.vdiff_pos_mv:.1f} / {self.vdiff_neg_mv:.1f} mV", + f" Common mode : {self.vcm_mv:+.1f} mV", + ] + if self.clock_freq_mhz is not None: + lines.append( + f" Clock freq : {self.clock_freq_mhz:.2f} MHz DDR " + f"({self.n_transitions} transitions)" + ) + if self.jitter_pp_ps is not None: + lines.append( + f" Jitter p-p/RMS : {self.jitter_pp_ps:.1f} ps / {self.jitter_rms_ps:.1f} ps" + ) + if self.rise_time_ps is not None: + lines.append( + f" Rise time 20-80%: {self.rise_time_ps:.1f} ps " + f"{ok(self.rise_time_ps <= RISE_FALL_MAX_PS)}" + ) + if self.fall_time_ps is not None: + lines.append( + f" Fall time 20-80%: {self.fall_time_ps:.1f} ps " + f"{ok(self.fall_time_ps <= RISE_FALL_MAX_PS)}" + ) + if self.spec_violations: + lines.append(f" Spec violations : {self.spec_violations} samples below {HS_VDIFF_MIN_MV:.0f} mV ✗") + for w in self.warnings: + lines.append(f" WARNING: {w}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _read_csv(path: Path) -> tuple[np.ndarray, np.ndarray]: + times, volts = [], [] + with open(path) as f: + for row in csv.reader(f): + if len(row) >= 2: + try: + times.append(float(row[0])) + volts.append(float(row[1])) + except ValueError: + pass # skip any header row + return np.array(times, dtype=np.float64), np.array(volts, dtype=np.float64) + + +def _zero_crossings(times: np.ndarray, volts: np.ndarray) -> np.ndarray: + """Return array of linearly-interpolated zero-crossing times (seconds).""" + signs = np.sign(volts) + change = np.diff(signs) + idx = np.where(change != 0)[0] + ct = [] + for i in idx: + if signs[i] != 0 and signs[i + 1] != 0: + frac = -volts[i] / (volts[i + 1] - volts[i]) + ct.append(times[i] + frac * (times[i + 1] - times[i])) + return np.array(ct) + + +def _rise_fall_times(times: np.ndarray, volts: np.ndarray, + v_high: float, v_low: float, + window_samples: int = 60) -> tuple[list, list]: + """ + Measure 20%–80% rise and fall times around each zero crossing. + Returns (rise_times_ps, fall_times_ps). + """ + v20 = v_low + 0.20 * (v_high - v_low) + v80 = v_low + 0.80 * (v_high - v_low) + + signs = np.sign(volts) + trans_idx = np.where(np.diff(signs) != 0)[0] + + rise_ps, fall_ps = [], [] + + for idx in trans_idx: + s = max(0, idx - window_samples // 2) + e = min(len(times), idx + window_samples // 2) + tw = times[s:e] + vw = volts[s:e] + if len(vw) < 4: + continue + + if volts[min(idx + 1, len(volts) - 1)] > volts[idx]: # rising edge + # find where vw first crosses v20 (ascending) then v80 + i20 = np.searchsorted(vw, v20) + i80 = np.searchsorted(vw, v80) + if 0 < i20 < len(tw) - 1 and 0 < i80 < len(tw) - 1 and i80 > i20: + # interpolate each threshold + t20 = np.interp(v20, vw[i20 - 1:i20 + 1], tw[i20 - 1:i20 + 1]) + t80 = np.interp(v80, vw[i80 - 1:i80 + 1], tw[i80 - 1:i80 + 1]) + rise_ps.append((t80 - t20) * 1e12) + else: # falling edge + # descending: reverse the window so searchsorted still works + vw_r = vw[::-1] + tw_r = tw[::-1] + i80 = np.searchsorted(vw_r, v80) + i20 = np.searchsorted(vw_r, v20) + if 0 < i80 < len(tw_r) - 1 and 0 < i20 < len(tw_r) - 1 and i20 > i80: + t80 = np.interp(v80, vw_r[i80 - 1:i80 + 1], tw_r[i80 - 1:i80 + 1]) + t20 = np.interp(v20, vw_r[i20 - 1:i20 + 1], tw_r[i20 - 1:i20 + 1]) + fall_ps.append((t20 - t80) * 1e12) + + return rise_ps, fall_ps + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def analyze_file(path: Path) -> ChannelMetrics: + """ + Analyse one oscilloscope CSV file and return a ChannelMetrics instance. + """ + m = re.match(r"(\d{8}_\d{6})_(sig|proto|lp)_(\d+)_(clk|dat)\.csv", + path.name, re.IGNORECASE) + if not m: + raise ValueError(f"Filename does not match expected pattern: {path.name}") + + timestamp, file_type, cap_str, channel = m.groups() + if file_type == "lp": + raise ValueError("Use analyze_lp_file() for lp-type files (single-ended)") + capture_num = int(cap_str) + + times, volts = _read_csv(path) + dt = float(np.diff(times).mean()) + sample_rate = 1.0 / dt + duration_ns = (float(times[-1]) - float(times[0])) * 1e9 + + # --- Voltage levels --- + v_thresh = TRANSITION_BAND_MV / 1000.0 + pos_mask = volts > v_thresh + neg_mask = volts < -v_thresh + + vdiff_pos = float(volts[pos_mask].mean()) * 1000.0 if pos_mask.any() else 0.0 + vdiff_neg = float(volts[neg_mask].mean()) * 1000.0 if neg_mask.any() else 0.0 + + # Classify signal coverage: + # no_signal — neither polarity detected (LP state or idle) + # one_sided — only one polarity in capture window (short sig window, uniform data) + no_signal = (not pos_mask.any()) and (not neg_mask.any()) + one_sided = (not no_signal) and ((not pos_mask.any()) or (not neg_mask.any())) + + if no_signal: + amplitude = 0.0 + elif one_sided: + amplitude = max(abs(vdiff_pos), abs(vdiff_neg)) + else: + amplitude = (abs(vdiff_pos) + abs(vdiff_neg)) / 2.0 + vcm = (vdiff_pos + vdiff_neg) / 2.0 + + # --- Zero crossings → frequency + jitter (CLK only) --- + ct = _zero_crossings(times, volts) + n_transitions = len(ct) + clock_freq_mhz = jitter_pp_ps = jitter_rms_ps = None + + # Jitter / frequency are only meaningful on the CLK lane. + # On DAT the bit pattern varies, so half-periods are not uniform by design. + # Require at least 20 transitions (10 full cycles) for reliable jitter. + # Sig files (~8 transitions) are too short; proto files (~4000) are fine. + if channel == "clk" and n_transitions >= 20: + half_periods = np.diff(ct) * 1e12 # ps + med = float(np.median(half_periods)) + sd = float(half_periods.std()) + # Remove outliers beyond 3σ (spurious glitches) + hp = half_periods[np.abs(half_periods - med) < 3.0 * sd] if sd > 0 else half_periods + if len(hp) >= 20: + clock_freq_mhz = round(1.0 / (float(np.median(hp)) * 2e-12) / 1e6, 2) + jitter_pp_ps = round(float(hp.max() - hp.min()), 1) + jitter_rms_ps = round(float(hp.std()), 1) + + # --- Rise / fall times --- + v_high = vdiff_pos / 1000.0 + v_low = vdiff_neg / 1000.0 + rise_list, fall_list = _rise_fall_times(times, volts, v_high, v_low) + rise_time_ps = round(float(np.median(rise_list)), 1) if rise_list else None + fall_time_ps = round(float(np.median(fall_list)), 1) if fall_list else None + + # --- Spec violations --- + # Only check samples that are well away from any zero crossing (bit-centres). + # Transitions naturally pass through sub-140 mV, so counting them as violations + # would be misleading. We mask out a ±guard window around each crossing. + guard_s = float(np.median(np.diff(ct))) * 0.35 if n_transitions >= 4 else dt * 10 + in_guard = np.zeros(len(times), dtype=bool) + for t_cross in ct: + lo = np.searchsorted(times, t_cross - guard_s) + hi = np.searchsorted(times, t_cross + guard_s) + in_guard[lo:hi] = True + + settled = (~in_guard) & (np.abs(volts) > v_thresh) + + # "Transient" violations: settled samples that dip noticeably below the + # measured settled amplitude (threshold = 85 % of the smaller settled level). + # This catches genuine dips without flagging cases where the settled level + # itself is just marginally below spec (which is reported as a WARNING instead). + floor_v = 0.85 * min(abs(vdiff_pos / 1000.0), abs(vdiff_neg / 1000.0)) if ( + vdiff_pos and vdiff_neg) else HS_VDIFF_MIN_MV / 1000.0 + spec_violations = int(np.sum(settled & (np.abs(volts) < floor_v))) + + # --- Warnings --- + warnings = [] + if no_signal: + warnings.append("No HS signal detected — line may be in LP state or idle") + elif one_sided: + polarity = "positive" if pos_mask.any() else "negative" + warnings.append( + f"Only {polarity} swings in capture window — amplitude may be underestimated" + ) + if not no_signal and amplitude < HS_VDIFF_MIN_MV: + warnings.append(f"Vdiff {amplitude:.0f} mV below spec min {HS_VDIFF_MIN_MV:.0f} mV") + if amplitude > HS_VDIFF_MAX_MV: + warnings.append(f"Vdiff {amplitude:.0f} mV above spec max {HS_VDIFF_MAX_MV:.0f} mV") + if rise_time_ps is not None and rise_time_ps > RISE_FALL_MAX_PS: + warnings.append(f"Rise time {rise_time_ps:.0f} ps exceeds {RISE_FALL_MAX_PS:.0f} ps") + if fall_time_ps is not None and fall_time_ps > RISE_FALL_MAX_PS: + warnings.append(f"Fall time {fall_time_ps:.0f} ps exceeds {RISE_FALL_MAX_PS:.0f} ps") + if spec_violations > 0: + warnings.append(f"{spec_violations} settled samples below {HS_VDIFF_MIN_MV:.0f} mV") + + return ChannelMetrics( + timestamp = timestamp, + capture_num = capture_num, + file_type = file_type, + channel = channel, + sample_rate_gsps = round(sample_rate / 1e9, 1), + duration_ns = round(duration_ns, 2), + n_samples = len(times), + vdiff_pos_mv = round(vdiff_pos, 1), + vdiff_neg_mv = round(vdiff_neg, 1), + vdiff_amplitude_mv = round(amplitude, 1), + vcm_mv = round(vcm, 1), + clock_freq_mhz = clock_freq_mhz, + jitter_pp_ps = jitter_pp_ps, + jitter_rms_ps = jitter_rms_ps, + rise_time_ps = rise_time_ps, + fall_time_ps = fall_time_ps, + n_transitions = n_transitions, + spec_violations = spec_violations, + warnings = warnings, + ) + + +def group_captures(data_dir: Path) -> dict[tuple[str, int], dict[str, Path]]: + """ + Scan data_dir and group CSV files by (timestamp, capture_number). + Returns dict mapping (timestamp, num) → {file_type_channel: Path}. + Example key: ("20260408_111448", 1) + Example value: {"sig_clk": Path(...), "sig_dat": ..., "proto_clk": ..., "proto_dat": ...} + """ + pattern = re.compile(r"(\d{8}_\d{6})_(sig|proto|lp)_(\d+)_(clk|dat)\.csv", re.IGNORECASE) + groups: dict[tuple[str, int], dict[str, Path]] = {} + for f in sorted(data_dir.glob("*.csv")): + m = pattern.match(f.name) + if not m: + continue + ts, ftype, cap_str, ch = m.groups() + key = (ts, int(cap_str)) + groups.setdefault(key, {})[f"{ftype}_{ch}"] = f + return groups + + +# --------------------------------------------------------------------------- +# LP state analysis (lp_clk / lp_dat — single-ended Ch1 / Ch3 captures) +# --------------------------------------------------------------------------- + +@dataclass +class LPMetrics: + timestamp: str + capture_num: int + channel: str # "clk" | "dat" + + sample_rate_gsps: float + duration_us: float + n_samples: int + + # LP-11 (both pins high ~1.2 V) + lp11_voltage_v: Optional[float] # mean level in LP-11 region (spec 1.0–1.45 V) + lp11_duration_us: Optional[float] # total LP-11 time in capture (pre-trigger) + + # LP-low (LP-01 + LP-00 combined — CLK+ = 0 V in both states) + lp_low_duration_ns: Optional[float] # duration between LP-11 end and HS start + + # HS bursts detected within the window + n_hs_bursts: int + hs_burst_dur_ns: Optional[float] # mean HS burst duration + hs_amplitude_mv: Optional[float] # peak-to-peak single-ended HS swing (mV) + + lp_transition_valid: bool # LP-11 → LP-low → HS sequence present + + warnings: list = field(default_factory=list) + + def summary(self) -> str: + ok = lambda c: "✓" if c else "✗" + lines = [ + f"Capture {self.capture_num:04d} {self.timestamp} [lp/{self.channel}]", + ] + if self.lp11_voltage_v is not None: + in_spec = LP11_SPEC_MIN_V <= self.lp11_voltage_v <= LP11_SPEC_MAX_V + lines.append( + f" LP-11 voltage : {self.lp11_voltage_v:.3f} V " + f"(spec {LP11_SPEC_MIN_V:.1f}–{LP11_SPEC_MAX_V:.2f} V) {ok(in_spec)}" + ) + if self.lp11_duration_us is not None: + lines.append(f" LP-11 duration : {self.lp11_duration_us:.2f} µs") + if self.lp_low_duration_ns is not None: + ok_lp = self.lp_low_duration_ns >= LP_LOW_DUR_MIN_NS + lines.append( + f" LP-low duration : {self.lp_low_duration_ns:.0f} ns " + f"(spec ≥{LP_LOW_DUR_MIN_NS:.0f} ns) {ok(ok_lp)}" + ) + lines.append( + f" LP→HS sequence : {'valid ✓' if self.lp_transition_valid else 'NOT DETECTED ✗'}" + ) + if self.n_hs_bursts: + lines.append(f" HS bursts : {self.n_hs_bursts}" + + (f" avg {self.hs_burst_dur_ns:.0f} ns" if self.hs_burst_dur_ns else "")) + if self.hs_amplitude_mv is not None: + lines.append(f" HS amplitude : {self.hs_amplitude_mv:.0f} mV (single-ended p-p/2)") + for w in self.warnings: + lines.append(f" WARNING: {w}") + return "\n".join(lines) + + +def _rolling_std(arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling standard deviation using stride_tricks (O(n) memory, fast).""" + from numpy.lib.stride_tricks import sliding_window_view + n = len(arr) + if n <= window: + return np.full(n, arr.std()) + windowed = sliding_window_view(arr, window) + stds = windowed.std(axis=1) + # Pad edges to maintain original length + pad_l = window // 2 + pad_r = n - len(stds) - pad_l + return np.concatenate([np.full(pad_l, stds[0]), stds, np.full(pad_r, stds[-1])]) + + +def _find_contiguous_regions(mask: np.ndarray, min_samples: int = 5): + """Return list of (start_idx, end_idx) for True runs ≥ min_samples long.""" + padded = np.concatenate([[False], mask, [False]]) + diff = np.diff(padded.astype(np.int8)) + starts = np.where(diff == 1)[0] + ends = np.where(diff == -1)[0] + return [(s, e) for s, e in zip(starts, ends) if (e - s) >= min_samples] + + +def analyze_lp_file(path: Path) -> "LPMetrics": + """ + Analyse a single-ended LP capture CSV (Ch1 or Ch3) and return LPMetrics. + + State classification per sample: + LP-11 : voltage > LP11_HIGH_V (~1.2 V, both pins high) + LP-low : voltage < LP_LOW_V (~0 V, pin driven low — LP-01 or LP-00) + HS : voltage in mid-range with high oscillation (rolling std > HS_OSC_STD_V) + trans : everything else (transitions between states) + """ + m = re.match(r"(\d{8}_\d{6})_lp_(\d+)_(clk|dat)\.csv", path.name, re.IGNORECASE) + if not m: + raise ValueError(f"Filename does not match lp pattern: {path.name}") + + timestamp, cap_str, channel = m.groups() + capture_num = int(cap_str) + + times, volts = _read_csv(path) + dt = float(np.diff(times).mean()) + sample_rate = 1.0 / dt + duration_us = (float(times[-1]) - float(times[0])) * 1e6 + + # ── State classification ────────────────────────────────────────────── + # Rolling std over ~1 ns window to detect HS oscillation + window = max(10, int(1e-9 / dt)) + rstd = _rolling_std(volts, window) + + lp11_mask = volts > LP11_HIGH_V + lp_low_mask = (volts < LP_LOW_V) & (rstd < HS_OSC_STD_V) + hs_mask = (~lp11_mask) & (~lp_low_mask) & (rstd >= HS_OSC_STD_V) + + # ── LP-11 region ────────────────────────────────────────────────────── + lp11_regions = _find_contiguous_regions(lp11_mask, min_samples=10) + lp11_voltage_v = None + lp11_duration_us = None + if lp11_regions: + lp11_voltage_v = round(float(np.concatenate( + [volts[s:e] for s, e in lp11_regions]).mean()), 3) + lp11_duration_us = round( + sum((times[e] - times[s]) for s, e in lp11_regions) * 1e6, 3) + + # ── LP-low region (between last LP-11 and first HS) ─────────────────── + lp_low_duration_ns = None + lp_transition_valid = False + + lp_low_regions = _find_contiguous_regions(lp_low_mask, min_samples=5) + hs_regions = _find_contiguous_regions(hs_mask, min_samples=20) + + if lp11_regions and lp_low_regions and hs_regions: + # Find the LP-low gap that sits between the last LP-11 and the first HS burst + last_lp11_end = lp11_regions[-1][1] + first_hs_start = hs_regions[0][0] + bridging = [(s, e) for s, e in lp_low_regions + if s >= last_lp11_end and e <= first_hs_start + int(100e-9 / dt)] + if bridging: + s0, e0 = bridging[0][0], bridging[-1][1] + lp_low_duration_ns = round((times[e0] - times[s0]) * 1e9, 1) + lp_transition_valid = True + + # ── HS burst metrics ────────────────────────────────────────────────── + n_hs_bursts = len(hs_regions) + hs_burst_dur_ns = None + hs_amplitude_mv = None + + if hs_regions: + durations = [(times[e] - times[s]) * 1e9 for s, e in hs_regions] + hs_burst_dur_ns = round(float(np.mean(durations)), 1) + + # HS single-ended amplitude: peak-to-peak / 2 of the oscillating signal + hs_volts = np.concatenate([volts[s:e] for s, e in hs_regions]) + hs_amplitude_mv = round( + (float(np.percentile(hs_volts, 95)) - float(np.percentile(hs_volts, 5))) / 2 * 1000, 1 + ) + + # ── Warnings ───────────────────────────────────────────────────────── + warnings = [] + if not lp11_regions: + warnings.append("No LP-11 state detected in capture window") + elif lp11_voltage_v is not None: + if lp11_voltage_v < LP11_SPEC_MIN_V: + warnings.append(f"LP-11 voltage {lp11_voltage_v:.3f} V below spec min {LP11_SPEC_MIN_V} V") + if lp11_voltage_v > LP11_SPEC_MAX_V: + warnings.append(f"LP-11 voltage {lp11_voltage_v:.3f} V above spec max {LP11_SPEC_MAX_V} V") + + if lp_low_duration_ns is not None and lp_low_duration_ns < LP_LOW_DUR_MIN_NS: + warnings.append( + f"LP-low duration {lp_low_duration_ns:.0f} ns below spec min {LP_LOW_DUR_MIN_NS:.0f} ns" + ) + + if not lp_transition_valid: + warnings.append("LP-11 → LP-low → HS transition sequence not detected") + + if n_hs_bursts == 0: + warnings.append("No HS bursts detected after LP transition") + + return LPMetrics( + timestamp = timestamp, + capture_num = capture_num, + channel = channel, + sample_rate_gsps = round(sample_rate / 1e9, 1), + duration_us = round(duration_us, 2), + n_samples = len(times), + lp11_voltage_v = lp11_voltage_v, + lp11_duration_us = lp11_duration_us, + lp_low_duration_ns = lp_low_duration_ns, + n_hs_bursts = n_hs_bursts, + hs_burst_dur_ns = hs_burst_dur_ns, + hs_amplitude_mv = hs_amplitude_mv, + lp_transition_valid = lp_transition_valid, + warnings = warnings, + ) + + +if __name__ == "__main__": + import sys + + data_dir = Path(__file__).parent / "data" + if len(sys.argv) > 1: + files = [Path(a) for a in sys.argv[1:]] + else: + files = sorted(data_dir.glob("*.csv"))[:8] # first 8 files as demo + + for f in files: + try: + if "_lp_" in f.name: + result = analyze_lp_file(f) + else: + result = analyze_file(f) + print(result.summary()) + print() + except Exception as e: + print(f"ERROR {f.name}: {e}") diff --git a/mipi_test.py b/mipi_test.py index 86998b4..c58dd99 100644 --- a/mipi_test.py +++ b/mipi_test.py @@ -30,6 +30,16 @@ SIG_POINTS = 500_000 # 500 k pts → ~25 GSa/s PROTO_SCALE = 1e-6 # 1 µs/div → 10 µs window PROTO_POINTS = 500_000 # 500 k pts → 50 MSa/s (enough to see burst structure) +# Pass 3 — LP state capture: widens vertical range to show LP-11 (~1.2 V) +# Channels reconfigured to 200 mV/div, offset +0.6 V → display spans −0.2 V to 1.4 V. +# Saves Ch1 (CLK+) and Ch3 (DAT0+) single-ended so LP-11/LP-00 are distinguishable. +# Trigger: falling edge on Ch1 at 0.6 V → catches LP-11 → LP-01 SoT transition. +LP_SCALE = 500e-9 # 500 ns/div → 5 µs window +LP_POINTS = 200_000 # 200 k pts → ~40 GSa/s +LP_V_SCALE = 0.2 # V/div — 8 divs = 1.6 V range +LP_V_OFFSET = 0.6 # V — center display at 0.6 V (range −0.2 V to 1.4 V) +LP_TRIG_LEVEL = 0.6 # V — midpoint of LP-11 (1.2 V) → LP-01 (0 V) fall + DISPLAY_SETTLE_S = 1.0 # seconds to wait after display ON before arming scope test_running = False # controls both worker threads @@ -215,6 +225,48 @@ def _save_pass(tag, iteration, ts): print(f" SAVE ERROR ({tag}): {e}") +def _save_pass_channels(tag, iteration, ts): + """ + Save Ch1 (CLK+) and Ch3 (DAT0+) as single-ended CSV for LP state analysis. + Single-ended is required for LP because differential (F1/F2) cannot distinguish + LP-11 (Vcm=1.2 V) from LP-00 (Vcm=0 V) — both give Vdiff=0. + """ + base = f"C:\\TEMP\\{ts}_{tag}_{iteration:04d}" + try: + scope.write(f':DISK:SAVE:WAVeform CHANnel1,"{base}_clk.csv",CSV') + time.sleep(2.5) + scope.write(f':DISK:SAVE:WAVeform CHANnel3,"{base}_dat.csv",CSV') + time.sleep(2.5) + print(f" SAVED: {base}_clk.csv {base}_dat.csv") + except Exception as e: + print(f" SAVE ERROR ({tag}): {e}") + + +def _configure_for_lp(): + """ + Widen channel vertical scales to capture LP states and switch to a + falling-edge trigger to catch the LP-11 → LP-01 SoT transition. + """ + for ch in (1, 2, 3, 4): + scope.write(f":CHANnel{ch}:SCALe {LP_V_SCALE:.3f}") + scope.write(f":CHANnel{ch}:OFFSet {LP_V_OFFSET:.3f}") + time.sleep(0.05) + scope.write(":TRIGger:EDGE:SLOPe NEGative") + scope.write(f":TRIGger:EDGE:LEVel {LP_TRIG_LEVEL:.3f}") + time.sleep(0.1) + + +def _restore_hs_config(): + """Restore HS-mode channel scales, offsets, and trigger after LP capture.""" + for ch in (1, 2, 3, 4): + scope.write(f":CHANnel{ch}:SCALe 0.1") + scope.write(f":CHANnel{ch}:OFFSet 0.0") + time.sleep(0.05) + scope.write(":TRIGger:EDGE:SLOPe POSitive") + scope.write(f":TRIGger:EDGE:LEVel 0.05") + time.sleep(0.1) + + def dual_capture(iteration): """ Two-pass capture per test iteration: @@ -242,6 +294,18 @@ def dual_capture(iteration): else: print(" SKIPPING PASS 2 SAVE.") + # ── Pass 3: LP / SoT structure ──────────────────────────────────────── + # Widens vertical range to capture LP-11 (1.2 V) and falls-edge triggers + # on the LP-11 → LP-01 SoT transition. Saves Ch1 and Ch3 single-ended. + print(" PASS 3: LP TRANSITION...") + _configure_for_lp() + _set_timebase(LP_SCALE, LP_POINTS) + if _arm_and_wait(timeout=30): + _save_pass_channels("lp", iteration, ts) + else: + print(" SKIPPING PASS 3 SAVE.") + _restore_hs_config() + # ── Restore original timebase ───────────────────────────────────────── _set_timebase(5e-9, 500_000) scope.write(":RUN")