Files
scheduler/backend/parser.py
T

302 lines
8.1 KiB
Python
Raw Normal View History

2026-06-16 15:07:59 -04:00
import csv
import os
from dataclasses import dataclass
from pathlib import Path
from db import DEVICE_DUT, DEVICE_REF, TestRecord
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",
]
RUNTIME_DEFAULTS = {
"P2P": 80,
"COE": 115,
"P3P": 105,
}
@dataclass(frozen=True)
class ParseResult:
tests: list[TestRecord]
warnings: list[str]
class CsvValidationError(ValueError):
pass
def parse_target_csv(csv_path: str | Path) -> ParseResult:
path = Path(csv_path)
if not path.exists():
raise FileNotFoundError(f"CSV file not found: {path}")
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CsvValidationError("CSV is missing a header row.")
normalized_fieldnames = {name.strip().upper() for name in reader.fieldnames if name}
# Check for required columns
missing_columns = [
c
for c in REQUIRED_COLUMNS
if c.strip().upper() not in normalized_fieldnames
]
if missing_columns:
raise CsvValidationError(
f"CSV is missing required columns: {', '.join(missing_columns)}"
)
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"Row {row_num}: missing TC ID, row skipped.")
continue
if test_id in seen_test_ids:
warnings.append(f"Row {row_num}: duplicate TC ID '{test_id}', row skipped.")
continue
seen_test_ids.add(test_id)
test_type = _infer_test_type(test_id)
rx_tx = _infer_rx_tx(test_id)
rotation = _empty_to_none(_row_get(row, "Rotation"))
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair"))
config = _build_config(row)
signature = _victim_band_signature(row)
estimated_minutes = RUNTIME_DEFAULTS.get(test_type)
victim_band = _normalize_victim_band(_row_get(row, "Victim Band"))
# Default priority based on test type and COE pairing
if test_type == "P2P":
priority = 2 if has_coe_pair else 3
elif test_type == "COE":
priority = 3
else:
priority = 4
record = TestRecord(
test_id=test_id,
device=DEVICE_DUT,
test_type=test_type,
rotation=rotation,
rx_tx=rx_tx,
has_coe_pair=has_coe_pair,
coe_pairing=[],
priority=priority,
victim_band=victim_band,
config=config,
estimated_minutes=estimated_minutes,
status="pending",
excluded=False,
raw_payload=row,
)
records_with_signature.append((record, signature))
# Build COE pairing based on victim band signature and RX/TX+band suffix.
# Example key suffixes: RXAX, TXAX, RXBE, TXBE.
coe_by_signature_and_suffix: dict[tuple, list[str]] = {}
for record, signature in records_with_signature:
if record.test_type == "COE" and signature is not None:
suffix = _extract_pairing_suffix(record.test_id)
if suffix:
key = (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
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
for device in (DEVICE_DUT, DEVICE_REF):
tests.append(
TestRecord(
test_id=record.test_id,
device=device,
test_type=record.test_type,
rotation=record.rotation,
rx_tx=record.rx_tx,
has_coe_pair=bool(pairs),
coe_pairing=pairs,
priority=record.priority,
victim_band=record.victim_band,
config=record.config,
estimated_minutes=record.estimated_minutes,
status=record.status,
excluded=record.excluded,
raw_payload=record.raw_payload,
)
)
return ParseResult(tests=tests, warnings=warnings)
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 _victim_band_signature(row: dict[str, str]) -> tuple[str, ...] | None:
band = _normalize_victim_band(_row_get(row, "Victim Band"))
if band is None:
return None
prefix = f"{band} "
test_point = _normalize_value(_row_get(row, f"{prefix}Test Point"))
channel = _normalize_value(_row_get(row, f"{prefix}Channel"))
rssi = _normalize_value(_row_get(row, f"{prefix}RSSI"))
bandwidth = _normalize_value(_row_get(row, f"{prefix}Bandwidth"))
# Direction is intentionally ignored for COE pairing matching.
if not all([test_point, channel, rssi, bandwidth]):
return None
return (band, test_point, channel, rssi, bandwidth)
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_yes_no(value: str | None) -> bool:
return _normalize_value(value) == "YES"
def _build_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": {
"power_mode": _empty_to_none(_row_get(row, "6GHz Power Mode")),
"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 _row_get(row: dict[str, str], key: str) -> str | None:
if key in row:
return row.get(key)
normalized_key = key.strip().upper()
for existing_key, value in row.items():
if existing_key and existing_key.strip().upper() == normalized_key:
return value
if normalized_key == "COE PAIR":
for alias in ("COE PAIRING", "COE_PAIRING"):
for existing_key, value in row.items():
if existing_key and existing_key.strip().upper() == alias:
return value
return None