#!/usr/bin/env python3 """ Automated carrier survey engine -- six-stage pipeline. Orchestrates spectrum sweep, peak detection, blind scan, and TS sampling to build a complete carrier catalog from the IF band. """ import sys import time import io from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES from signal_analysis import ( adaptive_noise_floor, detect_peaks_enhanced, estimate_carrier_bw, classify_carrier, ) from carrier_catalog import CarrierEntry, CarrierCatalog from ts_analyze import TSReader, PSIParser, parse_pat, parse_pmt, parse_sdt # Modulation index table for reverse lookup _MOD_BY_INDEX = {} for name, (idx, desc) in MODULATIONS.items(): _MOD_BY_INDEX[idx] = name class SurveyEngine: """ Six-stage carrier survey pipeline: 1. Coarse sweep -- full IF range at configurable step size 2. Peak detection -- adaptive noise floor, peak merging 3. Fine sweep -- +/-10 MHz around each peak at 1 MHz steps 4. Blind scan -- try symbol rate range at each refined peak 5. TS sample -- for locked carriers, short capture + PAT/PMT/SDT 6. Catalog assembly -- aggregate everything into a CarrierCatalog """ STAGE_COARSE = "coarse_sweep" STAGE_PEAKS = "peak_detection" STAGE_FINE = "fine_sweep" STAGE_BLIND = "blind_scan" STAGE_TS = "ts_sample" STAGE_CATALOG = "catalog_assembly" def __init__(self, device: SkyWalker1, callback=None): """ device -- open SkyWalker1 instance callback -- optional function(stage, progress_pct, message) called at each major step for progress reporting """ self.dev = device self.callback = callback def _report(self, stage: str, pct: float, msg: str) -> None: if self.callback: self.callback(stage, pct, msg) # ------------------------------------------------------------------ # Public entry points # ------------------------------------------------------------------ def run_full_scan(self, start_mhz: float = 950, stop_mhz: float = 2150, coarse_step: float = 5.0, fine_step: float = 1.0, sr_min: int = 1_000_000, sr_max: int = 30_000_000, sr_step: int = 1_000_000, ts_capture_secs: float = 3.0) -> CarrierCatalog: """ Run all six stages and return a populated CarrierCatalog. """ # Stage 1: coarse sweep self._report(self.STAGE_COARSE, 0, "Starting coarse sweep") freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, coarse_step) self._report(self.STAGE_COARSE, 100, f"Coarse sweep done: {len(freqs)} points") # Stage 2: peak detection self._report(self.STAGE_PEAKS, 0, "Detecting peaks") peaks = self._detect_peaks(freqs, powers) self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} candidate peaks") if not peaks: self._report(self.STAGE_CATALOG, 100, "No peaks found, empty catalog") return self._assemble_catalog([], start_mhz, stop_mhz, coarse_step, fine_step) # Stage 3: fine sweep around each peak self._report(self.STAGE_FINE, 0, "Starting fine sweeps") refined = self._fine_sweep(peaks, fine_step) self._report(self.STAGE_FINE, 100, f"Refined to {len(refined)} carriers") # Stage 4: blind scan at each refined peak self._report(self.STAGE_BLIND, 0, "Starting blind scan") scanned = self._blind_scan_peaks(refined, sr_min, sr_max, sr_step) self._report(self.STAGE_BLIND, 100, f"Blind scan done: {sum(1 for s in scanned if s.get('locked'))} locked") # Stage 5: TS sample for locked carriers locked = [s for s in scanned if s.get("locked")] self._report(self.STAGE_TS, 0, f"Sampling TS from {len(locked)} locked carriers") sampled = self._sample_ts(locked, capture_secs=ts_capture_secs) self._report(self.STAGE_TS, 100, "TS sampling done") # Stage 6: assemble catalog self._report(self.STAGE_CATALOG, 0, "Assembling catalog") catalog = self._assemble_catalog(sampled, start_mhz, stop_mhz, coarse_step, fine_step) self._report(self.STAGE_CATALOG, 100, f"Catalog ready: {len(catalog.carriers)} carriers") return catalog def run_quick_scan(self, start_mhz: float = 950, stop_mhz: float = 2150, step: float = 5.0) -> list: """ Quick scan: coarse sweep + peak detection only. Returns list of peak dicts from detect_peaks_enhanced. No blind scan or TS capture. """ self._report(self.STAGE_COARSE, 0, "Quick scan: coarse sweep") freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, step) self._report(self.STAGE_COARSE, 100, f"Sweep done: {len(freqs)} points") self._report(self.STAGE_PEAKS, 0, "Quick scan: peak detection") peaks = self._detect_peaks(freqs, powers) self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} peaks") return peaks # ------------------------------------------------------------------ # Internal stage methods # ------------------------------------------------------------------ def _coarse_sweep(self, start_mhz: float, stop_mhz: float, step: float) -> tuple: """ Stage 1: sweep the IF band and collect power measurements. Returns (freqs_mhz[], powers_db[]). """ total_steps = int((stop_mhz - start_mhz) / step) + 1 def sweep_cb(freq, step_num, total, result): pct = (step_num / max(total, 1)) * 100 self._report(self.STAGE_COARSE, pct, f"{freq:.0f} MHz {result['power_db']:+.1f} dB") freqs, powers, _ = self.dev.sweep_spectrum( start_mhz, stop_mhz, step_mhz=step, dwell_ms=15, callback=sweep_cb ) return freqs, powers def _detect_peaks(self, freqs: list, powers: list) -> list: """ Stage 2: enhanced peak detection with adaptive noise floor. Returns list of peak dicts. """ noise_floor, mad = adaptive_noise_floor(powers) self._report(self.STAGE_PEAKS, 50, f"Noise floor: {noise_floor:.1f} dB, MAD: {mad:.2f} dB") peaks = detect_peaks_enhanced(freqs, powers, threshold_db=6.0) # Annotate each peak with classification for p in peaks: p["classification"] = classify_carrier(p["width_mhz"], p["power"]) return peaks def _fine_sweep(self, peaks: list, fine_step: float = 1.0) -> list: """ Stage 3: sweep +/-10 MHz around each peak at fine resolution. Returns list of refined peak dicts with updated freq/power/width. """ refined = [] for i, peak in enumerate(peaks): pct = (i / max(len(peaks), 1)) * 100 center = peak["freq"] margin = max(peak["width_mhz"] * 1.5, 10.0) fine_start = max(950.0, center - margin) fine_stop = min(2150.0, center + margin) self._report(self.STAGE_FINE, pct, f"Fine sweep {center:.0f} MHz ({fine_start:.0f}-{fine_stop:.0f})") freqs, powers, _ = self.dev.sweep_spectrum( fine_start, fine_stop, step_mhz=fine_step, dwell_ms=20 ) # Re-detect peaks in the fine data fine_peaks = detect_peaks_enhanced(freqs, powers, threshold_db=4.0) if fine_peaks: # Take the strongest peak from the fine sweep best = max(fine_peaks, key=lambda p: p["power"]) best["classification"] = classify_carrier( best["width_mhz"], best["power"] ) refined.append(best) else: # Keep the coarse peak if fine sweep didn't improve it refined.append(peak) return refined def _blind_scan_peaks(self, refined_peaks: list, sr_min: int, sr_max: int, sr_step: int) -> list: """ Stage 4: attempt blind scan at each refined peak frequency. Returns list of result dicts, each with the peak info plus blind scan results (locked, sr_sps, etc). """ results = [] for i, peak in enumerate(refined_peaks): pct = (i / max(len(refined_peaks), 1)) * 100 freq_khz = int(peak["freq"] * 1000) self._report(self.STAGE_BLIND, pct, f"Blind scan {peak['freq']:.1f} MHz") # Use classification to narrow SR range if possible cls = peak.get("classification", {}) sr_range = cls.get("estimated_sr_range", (sr_min, sr_max)) scan_min = max(sr_min, sr_range[0]) scan_max = min(sr_max, sr_range[1]) result = { "freq_mhz": peak["freq"], "freq_khz": freq_khz, "power_db": peak["power"], "width_mhz": peak["width_mhz"], "prominence_db": peak.get("prominence_db", 0), "classification": cls, "locked": False, "sr_sps": 0, "mod_index": -1, "fec_index": -1, } # Try adaptive blind scan first (firmware-assisted) try: lock = self.dev.adaptive_blind_scan( freq_khz, scan_min, scan_max, sr_step ) if lock and lock.get("locked"): result["locked"] = True result["sr_sps"] = lock["sr_sps"] result["freq_khz"] = lock.get("freq_khz", freq_khz) # Read signal quality time.sleep(0.1) sig = self.dev.signal_monitor() result["snr_db"] = sig.get("snr_db", 0) result["agc1"] = sig.get("agc1", 0) except Exception as e: self._report(self.STAGE_BLIND, pct, f"Blind scan error at {peak['freq']:.1f} MHz: {e}") results.append(result) return results def _sample_ts(self, locked_carriers: list, capture_secs: float = 3.0) -> list: """ Stage 5: for each locked carrier, tune + arm + capture TS data, then parse PAT/PMT/SDT for service information. """ results = [] for i, carrier in enumerate(locked_carriers): pct = (i / max(len(locked_carriers), 1)) * 100 freq_khz = carrier["freq_khz"] sr_sps = carrier["sr_sps"] self._report(self.STAGE_TS, pct, f"Sampling {carrier['freq_mhz']:.1f} MHz " f"SR={sr_sps / 1e6:.3f} Msps") carrier["services"] = [] carrier["pat"] = None carrier["pmt"] = {} if sr_sps <= 0: results.append(carrier) continue try: # Tune with QPSK auto-FEC as a safe default self.dev.tune(sr_sps, freq_khz, 0, 5) time.sleep(0.3) # Verify lock sig = self.dev.signal_monitor() if not sig.get("locked"): results.append(carrier) continue carrier["snr_db"] = sig.get("snr_db", 0) # Arm and capture TS data self.dev.arm_transfer(True) ts_data = bytearray() deadline = time.time() + capture_secs while time.time() < deadline: chunk = self.dev.read_stream(timeout=500) if chunk: ts_data.extend(chunk) self.dev.arm_transfer(False) # Parse the captured TS if ts_data: services = _parse_ts_services(bytes(ts_data)) carrier["services"] = services.get("service_names", []) carrier["pat"] = services.get("pat") carrier["pmt"] = services.get("pmts", {}) carrier["sdt"] = services.get("sdt") except Exception as e: self._report(self.STAGE_TS, pct, f"TS capture error at {carrier['freq_mhz']:.1f} MHz: {e}") try: self.dev.arm_transfer(False) except Exception: pass results.append(carrier) return results def _assemble_catalog(self, all_results: list, start_mhz: float = 950, stop_mhz: float = 2150, coarse_step: float = 5.0, fine_step: float = 1.0) -> CarrierCatalog: """ Stage 6: build a CarrierCatalog from the collected results. """ catalog = CarrierCatalog() catalog.sweep_params = { "start_mhz": start_mhz, "stop_mhz": stop_mhz, "coarse_step_mhz": coarse_step, "fine_step_mhz": fine_step, } for r in all_results: mod_name = "" if r.get("mod_index", -1) >= 0: mod_name = _MOD_BY_INDEX.get(r["mod_index"], "") entry = CarrierEntry( freq_khz=r.get("freq_khz", int(r.get("freq_mhz", 0) * 1000)), sr_sps=r.get("sr_sps", 0), modulation=mod_name, fec="", power_db=r.get("power_db", 0), snr_db=r.get("snr_db", 0), locked=r.get("locked", False), services=r.get("services", []), bw_mhz=r.get("width_mhz", 0), classification=r.get("classification", {}), ) catalog.add_carrier(entry) return catalog def _parse_ts_services(ts_data: bytes) -> dict: """ Parse PAT, PMT, and SDT from a chunk of TS data. Returns dict with: pat - parsed PAT or None pmts - {pmt_pid: parsed PMT} sdt - parsed SDT or None service_names - list of service name strings from SDT """ result = { "pat": None, "pmts": {}, "sdt": None, "service_names": [], } source = io.BytesIO(ts_data) reader = TSReader(source) psi_pat = PSIParser() psi_pmt = PSIParser() psi_sdt = PSIParser() pat = None pmt_pids = set() pmts_found = {} try: for pkt in reader.iter_packets(max_packets=50000): # PAT on PID 0x0000 if pkt.pid == 0x0000 and pat is None: section = psi_pat.feed(pkt) if section is not None: pat = parse_pat(section) if pat: result["pat"] = pat for prog, pid in pat["programs"].items(): if prog != 0: pmt_pids.add(pid) # PMT sections if pkt.pid in pmt_pids and pkt.pid not in pmts_found: section = psi_pmt.feed(pkt) if section is not None: pmt = parse_pmt(section) if pmt: pmts_found[pkt.pid] = pmt # SDT on PID 0x0011 if pkt.pid == 0x0011 and result["sdt"] is None: section = psi_sdt.feed(pkt) if section is not None: sdt = parse_sdt(section) if sdt: result["sdt"] = sdt for svc in sdt.get("services", []): name = svc.get("service_name", "") if name: result["service_names"].append(name) # Stop early once we have everything if (pat is not None and len(pmts_found) >= len(pmt_pids) and result["sdt"] is not None): break except Exception: pass result["pmts"] = pmts_found return result