"""Leg F analyzer: the PC's latency while ANOTHER device loads the line, by marked windows. The PC is idle in Leg F, so its own traffic can't define phases the way analyze_v2.py does. Instead each run's window starts at its start mark in markers.csv (markers.ps1), and the baseline is the PC's UDP median over the 30 s before it. The window is a fixed --span seconds from the start (default 45, the plan's value): the phone's Waveform load sits in its first ~40 s, and the wait for the result screen after it would water down the p95 (found in the 2026-10-17 dry run). The end mark only confirms the test ran past the span. --span 0 analyzes start to end. Percentile and "added" math are analyze_v2.py's, so the numbers are comparable. Usage: python analyze_window.py probe.csv markers.csv [routerping.csv] [--span 45] """ import csv, statistics, sys args = sys.argv[1:] SPAN = 45.0 if '--span' in args: i = args.index('--span'); SPAN = float(args[i + 1]); del args[i:i + 2] probe, markers = args[0], args[1] router = args[2] if len(args) > 2 else None BASE_S, IDLE_MBPS = 30.0, 5.0 def secs(ts): return int(ts[:2]) * 3600 + int(ts[3:5]) * 60 + float(ts[6:]) def pct(v, p): # analyze.py's percentile v = sorted(v); return v[min(len(v) - 1, int(round(p / 100 * (len(v) - 1))))] rows = list(csv.DictReader(open(probe))) for r in rows: r['t'] = secs(r['ts']) for k in ('udp_ms', 'icmp_ms', 'tx_mbps', 'rx_mbps'): r[k] = float(r[k]) gw = [(secs(r['ts']), float(r['gw_ms'])) for r in csv.DictReader(open(router))] if router else [] runs = {} for m in csv.DictReader(open(markers)): runs.setdefault(m['run'], {})[m['mark']] = secs(m['ts']) for name, m in runs.items(): if 'start' not in m or 'end' not in m: print(f"{name}: incomplete markers {sorted(m)}; skipped") continue s, marked_end = m['start'], m['end'] short = SPAN and marked_end - s < SPAN e = s + SPAN if SPAN else marked_end base = [r for r in rows if s - BASE_S <= r['t'] < s] win = [r for r in rows if s <= r['t'] <= e] bu = [r['udp_ms'] for r in base if r['udp_ms'] > 0] bi = [r['icmp_ms'] for r in base if r['icmp_ms'] >= 0] u = [r['udp_ms'] for r in win if r['udp_ms'] > 0] if not bu or not u: print(f"{name}: no valid samples (baseline {len(bu)}, window {len(u)}); INVALID") continue b = statistics.median(bu) times = [r['t'] for r in win] gap = max((y - x for x, y in zip(times, times[1:])), default=0) busy = [r for r in base + win if r['tx_mbps'] > IDLE_MBPS or r['rx_mbps'] > IDLE_MBPS] peak = max((max(r['tx_mbps'], r['rx_mbps']) for r in base + win), default=0) over = sum(1 for x in u if x - b > 100) lost = sum(1 for r in win if r['udp_ms'] < 0) print(f"{name}: window {e - s:.0f}s of {marked_end - s:.0f}s marked, {len(win)} samples, max gap {gap:.2f}s | baseline udp {b:.1f}ms (n={len(bu)})") line = (f" UDP added: median +{statistics.median(u) - b:.0f}ms p95 +{pct(u, 95) - b:.0f}ms " f"worst +{max(u) - b:.0f}ms over+100 {over}/{len(u)} timeouts {lost}") i = [r['icmp_ms'] for r in win if r['icmp_ms'] >= 0] if i and bi: bi_m = statistics.median(bi) line += f" | ICMP added: median +{statistics.median(i) - bi_m:.0f}ms p95 +{pct(i, 95) - bi_m:.0f}ms" print(line) if gw: g = [ms for t, ms in gw if s <= t <= e and ms >= 0] gl = sum(1 for t, ms in gw if s <= t <= e and ms < 0) if g: print(f" router ping: median {statistics.median(g):.0f}ms p95 {pct(g, 95):.0f}ms worst {max(g):.0f}ms lost {gl}") flags = [] if short: flags.append(f"result marked {marked_end - s:.0f}s after start, inside the {SPAN:.0f}s span (test cut short?)") if gap > 2: flags.append(f"probe gap {gap:.1f}s > 2s") if busy: flags.append(f"PC not idle: {len(busy)} samples over {IDLE_MBPS:.0f} Mbps (peak {peak:.1f})") print(" " + ("INVALID: " + "; ".join(flags) if flags else f"PC idle (peak {peak:.1f} Mbps), no gaps: valid on the PC side"))