import csv import os import re from dataclasses import dataclass from pathlib import Path from typing import Any from db import DUT, REF, TestRecord from file_manager import ( open_csv_handle ) TEST_TYPES = {"P2P", "COE", "P3P"} POWER_MODES = {"LPI", "SP"} RX_TX = {"RX", "TX"} P2P_COE_REQUIRED_COLUMNS = [ "Priority", "Index", "Interferer", "COE Pair", "Rotation", "TC ID", "Victim Band", "6GHz Power Mode", "5G_Test Point", "5G_Channel", "5G_Bandwidth", "5G_RSSI", "5G_Direction", "5G_STA", "6G_Test Point", "6G_Channel", "6G_Bandwidth", "6G_RSSI", "6G_Direction", "6G_STA", "2G_Test Point", "2G_Channel", "2G_Bandwidth", "2G_RSSI", "2G_Direction", "2G_STA", ] P3P_REQUIRED_COLUMNS = [ "Priority", "Index", "Throttled", "Rotation", "TC ID", "Band", "6GHz Power Mode", "STATION 1_Test Point", "STATION 1_Channel", "STATION 1_Bandwidth", "STATION 1_RSSI", "STATION 1_Direction", "STATION 1_Rate", "STATION 1_STA", "STATION 2_Test Point", "STATION 2_Channel", "STATION 2_Bandwidth", "STATION 2_RSSI", "STATION 2_Direction", "STATION 2_Rate", "STATION 2_STA", "STATION 3_Test Point", "STATION 3_Channel", "STATION 3_Bandwidth", "STATION 3_RSSI", "STATION 3_Direction", "STATION 3_Rate", "STATION 3_STA", ] RUNTIME_DEFAULTS = { "P2P": 80, "COE": 115, "P3P": 105, } @dataclass(frozen=True) class ParseResult: tests: list[TestRecord] warnings: list[str] class CsvValidationError(ValueError): pass _SMB_SESSIONS: set[str] = set() def _columns_missing(normalized_fieldnames: set[str], required_columns: list[str]) -> list[str]: return [ column for column in required_columns if _normalize_column_name(column) not in normalized_fieldnames ] def _detect_csv_format(normalized_fieldnames: set[str]) -> str: p2p_coe_missing = _columns_missing(normalized_fieldnames, P2P_COE_REQUIRED_COLUMNS) if not p2p_coe_missing: return "p2p_coe" p3p_missing = _columns_missing(normalized_fieldnames, P3P_REQUIRED_COLUMNS) if not p3p_missing: return "p3p" raise CsvValidationError( "CSV format not recognized. Missing columns for P2P/COE format: " f"{', '.join(p2p_coe_missing)}; missing columns for P3P format: {', '.join(p3p_missing)}" ) def parse_target_csv( paths: str | Path | list[str | Path] | tuple[str | Path, ...], smb_credentials: dict[str, Any] | None = None, runtime_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: runtime_defaults_by_device = _resolve_runtime_defaults(runtime_overrides) results = {} if isinstance(paths, (str, Path)): paths = [paths] for path in paths: csv_result = _parse_single_csv( path, smb_credentials=smb_credentials, runtime_defaults_by_device=runtime_defaults_by_device, ) results.update(csv_result) return results def _coerce_runtime_defaults(raw_overrides: dict[str, Any] | None) -> dict[str, int]: defaults = dict(RUNTIME_DEFAULTS) if not raw_overrides: return defaults for test_type in ("P2P", "COE", "P3P"): raw_value = raw_overrides.get(test_type) if raw_value is None: continue if isinstance(raw_value, str): raw_value = raw_value.strip() if not raw_value: continue try: minutes = int(raw_value) except (TypeError, ValueError): continue if minutes > 0: defaults[test_type] = minutes return defaults def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, dict[str, int]]: legacy_defaults = _coerce_runtime_defaults(runtime_overrides) if not runtime_overrides: return { DUT: dict(legacy_defaults), REF: dict(legacy_defaults), } resolved: dict[str, dict[str, int]] = {} for device in (DUT, REF): device_defaults = dict(legacy_defaults) raw_device_overrides = runtime_overrides.get(device) if isinstance(raw_device_overrides, dict): device_defaults.update(_coerce_runtime_defaults(raw_device_overrides)) resolved[device] = device_defaults return resolved def _parse_single_csv( path: str, smb_credentials: dict[str, Any] | None, runtime_defaults_by_device: dict[str, dict[str, int]], ) -> dict[str, Any]: records: dict[str, Any] = {} with open_csv_handle(path, smb_credentials=smb_credentials) as handle: reader = csv.DictReader(handle) if not reader.fieldnames: raise CsvValidationError("CSV is missing a header row.") normalized_fieldnames = {_normalize_column_name(name) for name in reader.fieldnames if name} csv_format = _detect_csv_format(normalized_fieldnames) tests: list[TestRecord] = [] warnings: list[str] = [] seen_test_ids: set[str] = set() records_with_signature: list[tuple[TestRecord, tuple[str, ...] | None]] = [] for row_num, row in enumerate(reader, start=2): test_id = (row.get("TC ID") or "").strip() if not test_id: warnings.append(f"File {path}, row {row_num}: missing TC ID, row skipped.") continue if test_id in seen_test_ids: warnings.append(f"File {path}, row {row_num}: duplicate TC ID '{test_id}', row skipped.") continue seen_test_ids.add(test_id) throttled = _normalize_yes_no(_row_get(row, "Throttled")) if csv_format == "p3p" else False test_type = _infer_test_type(test_id) rx_tx = _infer_rx_tx(test_id) rotation = _empty_to_none(_row_get(row, "Rotation")) power_mode = _empty_to_none(_row_get(row, "6GHz Power Mode")) has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False config = _build_config(row, csv_format) signature = _first_populated_p2p_signature(row) if csv_format == "p2p_coe" and test_type == "P2P" else None victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band" victim_band = _normalize_victim_band(_row_get(row, victim_band_source)) records[test_id] = { "test_id": test_id, "test_type": test_type, "rotation": rotation, "rx_tx": rx_tx, "power_mode": power_mode, "has_coe_pair": has_coe_pair, "coe_pairing": [], "victim_band": victim_band, "config": config, "throttled": throttled, "estimated_minutes": {device: defaults[test_type] for device, defaults in runtime_defaults_by_device.items()}, "status": "pending", "excluded": False, "raw_payload": row, } records_with_signature.append((records[test_id], signature)) # Build COE pairing based on full signature and RX/TX+band suffix. # Example key suffixes: RXAX, TXAX, RXBE, TXBE. coe_by_signature_and_suffix: dict[tuple[tuple[str, ...], str], list[str]] = {} for record, signature in records_with_signature: if record["test_type"] == "COE": suffix = _extract_pairing_suffix(record["test_id"]) if not suffix: continue # Index all populated COE band signatures so any one can match the selected P2P signature. for coe_signature in _all_band_signatures(record["raw_payload"] or {}): key = (coe_signature, suffix) coe_by_signature_and_suffix.setdefault(key, []).append(record["test_id"]) for record, signature in records_with_signature: pairs = record["coe_pairing"] if record["test_type"] == "P2P" and signature is not None: suffix = _extract_pairing_suffix(record["test_id"]) key = (signature, suffix) if suffix else None print(f"[parser] P2P test {record['test_id']} with signature {signature} and suffix {suffix} has COE pairs: {coe_by_signature_and_suffix.get(key, [])}") pairs = sorted(set(coe_by_signature_and_suffix.get(key, []))) if key else [] records[record["test_id"]].update( has_coe_pair=bool(pairs), coe_pairing=pairs, ) return records def _infer_test_type(test_id: str) -> str: token = test_id.upper() if token.startswith("COE"): return "COE" if token.startswith("P3P"): return "P3P" return "P2P" def _infer_rx_tx(test_id: str) -> str | None: token = test_id.upper() if "RX" in token: return "RX" if "TX" in token: return "TX" return None def _extract_pairing_suffix(test_id: str) -> str | None: """Extract the RX/TX+band suffix used for pairing. Format: [COE|P2P][RX|TX][Band][Number] Example: P2PRXAC004 -> 'RXAC', COETXAX012 -> 'TXAX' """ token = test_id.upper() # Remove COE/P2P prefix if token.startswith("COE"): token = token[3:] elif token.startswith("P2P") or token.startswith("P3P"): token = token[3:] else: return None # Extract RX/TX + band (for example RXAX, TXBE). if len(token) >= 4 and (token.startswith("RX") or token.startswith("TX")): return token[:4] return None def _empty_to_none(value: str | None) -> str | None: if value is None: return None cleaned = value.strip() return cleaned if cleaned else None def _first_populated_p2p_signature(row: dict[str, str]) -> tuple[str, ...] | None: for band in ("5G", "6G", "2G"): signature = _band_signature(row, band) if signature is not None: return signature return None def _band_signature(row: dict[str, str], band: str) -> tuple[str, ...] | None: normalized_band = _normalize_victim_band(band) if normalized_band is None: return None # Source CSVs may use either "5G_Test Point" or "5G Test Point" style headers. prefixes = (f"{normalized_band}_", f"{normalized_band} ") def _pick_value(suffix: str) -> str: for prefix in prefixes: value = _row_get(row, f"{prefix}{suffix}") if _normalize_value(value): return _normalize_value(value) return "" test_point = _pick_value("Test Point") channel = _pick_value("Channel") rssi = _pick_value("RSSI") bandwidth = _pick_value("Bandwidth") # Pairing should ignore direction so reverse-signed COE and P2P rows still match. sta = _pick_value("STA") if not all([test_point, channel, rssi, bandwidth, sta]): return None return (test_point, channel, bandwidth, rssi, sta) def _all_band_signatures(row: dict[str, str]) -> list[tuple[str, ...]]: seen: set[tuple[str, ...]] = set() signatures: list[tuple[str, ...]] = [] for band in ("5G", "6G", "2G"): signature = _band_signature(row, band) if signature is not None and signature not in seen: seen.add(signature) signatures.append(signature) return signatures def _normalize_victim_band(value: str | None) -> str | None: if value is None: return None normalized = value.strip().upper().replace("GHZ", "G") if normalized.startswith("5"): return "5G" if normalized.startswith("6"): return "6G" if normalized.startswith("2"): return "2G" return None def _normalize_value(value: str | None) -> str: if value is None: return "" return " ".join(value.strip().upper().split()) def _normalize_column_name(value: str | None) -> str: if value is None: return "" # Accept common header variants, for example: "TC ID", "TC_ID", or "TC\u00A0ID". return re.sub(r"[^A-Z0-9]+", "", value.upper()) def _normalize_yes_no(value: str | None) -> bool: return _normalize_value(value) == "YES" def _build_p2p_coe_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]: return { "5G": { "test_point": _empty_to_none(_row_get(row, "5G_Test Point")), "channel": _empty_to_none(_row_get(row, "5G_Channel")), "bandwidth": _empty_to_none(_row_get(row, "5G_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "5G_RSSI")), "direction": _empty_to_none(_row_get(row, "5G_Direction")), "sta": _empty_to_none(_row_get(row, "5G_STA")), }, "6G": { "test_point": _empty_to_none(_row_get(row, "6G_Test Point")), "channel": _empty_to_none(_row_get(row, "6G_Channel")), "bandwidth": _empty_to_none(_row_get(row, "6G_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "6G_RSSI")), "direction": _empty_to_none(_row_get(row, "6G_Direction")), "sta": _empty_to_none(_row_get(row, "6G_STA")), }, "2G": { "test_point": _empty_to_none(_row_get(row, "2G_Test Point")), "channel": _empty_to_none(_row_get(row, "2G_Channel")), "bandwidth": _empty_to_none(_row_get(row, "2G_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "2G_RSSI")), "direction": _empty_to_none(_row_get(row, "2G_Direction")), "sta": _empty_to_none(_row_get(row, "2G_STA")), }, } def _build_p3p_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]: return { "STATION1": { "test_point": _empty_to_none(_row_get(row, "STATION 1_Test Point")), "channel": _empty_to_none(_row_get(row, "STATION 1_Channel")), "bandwidth": _empty_to_none(_row_get(row, "STATION 1_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "STATION 1_RSSI")), "direction": _empty_to_none(_row_get(row, "STATION 1_Direction")), "rate": _empty_to_none(_row_get(row, "STATION 1_Rate")), "sta": _empty_to_none(_row_get(row, "STATION 1_STA")), }, "STATION2": { "test_point": _empty_to_none(_row_get(row, "STATION 2_Test Point")), "channel": _empty_to_none(_row_get(row, "STATION 2_Channel")), "bandwidth": _empty_to_none(_row_get(row, "STATION 2_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "STATION 2_RSSI")), "direction": _empty_to_none(_row_get(row, "STATION 2_Direction")), "rate": _empty_to_none(_row_get(row, "STATION 2_Rate")), "sta": _empty_to_none(_row_get(row, "STATION 2_STA")), }, "STATION3": { "test_point": _empty_to_none(_row_get(row, "STATION 3_Test Point")), "channel": _empty_to_none(_row_get(row, "STATION 3_Channel")), "bandwidth": _empty_to_none(_row_get(row, "STATION 3_Bandwidth")), "rssi": _empty_to_none(_row_get(row, "STATION 3_RSSI")), "direction": _empty_to_none(_row_get(row, "STATION 3_Direction")), "rate": _empty_to_none(_row_get(row, "STATION 3_Rate")), "sta": _empty_to_none(_row_get(row, "STATION 3_STA")), }, } def _build_config(row: dict[str, str], csv_format: str) -> dict[str, dict[str, str | None]]: if csv_format == "p3p": return _build_p3p_config(row) return _build_p2p_coe_config(row) def _row_get(row: dict[str, str], key: str) -> str | None: if key in row: return row.get(key) normalized_key = _normalize_column_name(key) for existing_key, value in row.items(): if existing_key and _normalize_column_name(existing_key) == normalized_key: return value if normalized_key == _normalize_column_name("COE PAIR"): for alias in ("COE PAIRING", "COE_PAIRING"): for existing_key, value in row.items(): if existing_key and _normalize_column_name(existing_key) == _normalize_column_name(alias): return value return None def parse_target_filename(filename, parent_dir): base_name = re.sub(r"\.ini$", "", filename, flags=re.IGNORECASE).upper() segments = base_name.split("_") test_type = next((s for s in segments if s in TEST_TYPES), None) test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None) device = next((s for s in segments if s in (DUT, REF)), None) if test_id is None or test_type is None or device is None: print(f"Warning: Could not parse test_id, test_type, or device from filename '{filename}'") return None test_id = test_id[2:] if test_id else None throttled = None if test_type == "P3P" and test_id: # P3P test_id carries throttle marker: TH = throttled, otherwise UT. throttled = True if "TH" in test_id else False rx_tx = "RX" if "RX" in test_id else "TX" band = next((s for s in segments if re.match(r"^\dGHZ$", s)), None) bandwidth = next((s for s in segments if re.match(r"^BW\d+$", s)), None) pm = next ((s for s in segments if s in POWER_MODES), None) rotation = None if parent_dir: rotation = next((s for s in parent_dir.split("_") if re.match(r"^ROT\d+$", s)), None) return { "test_id": test_id, "device": device, "test_type": test_type, "rotation": rotation, "rx_tx": rx_tx, "power_mode": pm, "has_coe_pair": None, "coe_pairing": [], "victim_band": band, "config_json": {}, "throttled": throttled, "estimated_minutes": None, "status": "pending", "excluded": False, "raw_payload": {}, "station_testpoint_map": {}, }