"""Lab062: подготовка полного 500K PHY-сеанса и RX-only preflight.""" from __future__ import annotations from dataclasses import asdict import hashlib import json from pathlib import Path import subprocess import sys import time if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np from experiments import lab049_bpsk_symbol_rate as phy from experiments import lab053_packet_processing as packet_processing from experiments import lab053_streaming_receiver as streaming from experiments import lab054_hardware_control as lab054 from experiments import lab056_abab_energy as measurements from experiments import lab056_capture_runner as hardware ROOT = Path(__file__).resolve().parents[1] RAW = ROOT / "data/raw/lab062" OUTPUT = ROOT / "data/processed/lab062" POINT = hardware.OperatingPoint( frequency_hz=1_400_000_000, physical_path="MASTER TX near window -> LOS indoor -> SLAVE RX on desk", path_confirmed=True, tx_antenna="existing 1400 MHz antenna near window", rx_antenna="existing 1400 MHz antenna on desk", distance_m=1.2, orientation="same polarization; fixed during preflight and RF session", visibility="LOS indoor", ) MODE = phy.PhyMode(500_000) PACKETS = 16 PAYLOAD_BYTES = 256 PREFLIGHT_SAMPLES = 800_000 RX_SECONDS = 3 RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz RX_LEAD_SECONDS = 0.5 STRONG_AC_RMS_CODE = 20.0 STRONG_COMPONENT_CODE = 100.0 def save_new(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("x", encoding="utf-8") as stream: json.dump(value, stream, ensure_ascii=False, indent=2) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: while block := stream.read(1024 * 1024): digest.update(block) return digest.hexdigest() def plans(): result = lab054.plans() if len(result) != PACKETS or any(len(frame.plan.payload) != PAYLOAD_BYTES for frame in result): raise RuntimeError("Штатный генератор не дал 16 payload по 256 байт") return result def parameters() -> dict: return { "rf_frequency_hz": POINT.frequency_hz, "sample_rate_hz": MODE.sample_rate_hz, "symbol_rate": MODE.symbol_rate, "samples_per_symbol": MODE.samples_per_symbol, "digital_carrier_hz": lab054.phy.lab045.CARRIER_OFFSET_HZ, "packets": PACKETS, "payload_bytes": PAYLOAD_BYTES, "tx_vga_db": 10, "rx_lna_db": 24, "rx_vga_db": 24, "rf_amplifier": False, "bias_tee": False, "geometry": asdict(POINT), "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", } def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]: return hardware.command(POINT, path, samples, transmit=transmit) def prepare() -> dict: RAW.mkdir(parents=True, exist_ok=True) OUTPUT.mkdir(parents=True, exist_ok=True) waveform_path = RAW / "tx_stream.cs8" if waveform_path.exists(): raise FileExistsError("Lab062 TX уже существует; перезапись запрещена") with waveform_path.open("xb") as stream: for frame in plans(): stream.write(phy.build_parameterized_tx_cs8(frame.plan, MODE)) samples = waveform_path.stat().st_size // 2 result = { "lab": "Lab062", "stage": "PREPARATION_ONLY", "rf_tx_count": 0, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "parameters": parameters(), "tx_samples": samples, "tx_bytes": waveform_path.stat().st_size, "tx_duration_seconds": samples / MODE.sample_rate_hz, "tx_sha256": sha256(waveform_path), "rx_samples": RX_SAMPLES, "rx_duration_seconds": RX_SECONDS, "automatic_retry": False, "tx_command_prepared_not_run": transfer_command(waveform_path, samples, transmit=True), "rx_command_prepared_not_run": transfer_command(RAW / "rx_stream.cs8", RX_SAMPLES), } save_new(RAW / "tx_metadata.json", result) save_new(OUTPUT / "preparation.json", result) return result def validate_prepared() -> dict: metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8")) path = RAW / "tx_stream.cs8" expected = hashlib.sha256() expected_bytes = 0 for frame in plans(): block = phy.build_parameterized_tx_cs8(frame.plan, MODE) expected.update(block) expected_bytes += len(block) if (metadata.get("stage") != "PREPARATION_ONLY" or metadata.get("rf_tx_count") != 0 or metadata.get("parameters") != parameters() or path.stat().st_size != expected_bytes or metadata.get("tx_sha256") != expected.hexdigest() or sha256(path) != expected.hexdigest()): raise RuntimeError("Подготовленный Lab062 TX или параметры изменились") return metadata def decode(mode: str, source_path: Path | None = None, *, diagnostics: bool = False) -> dict: observations = [] receiver = streaming.StreamingReceiver( processor=packet_processing.PacketProcessor(carrier_tracking_mode=mode), packet_observer=lambda row, outcome: observations.append((row, outcome)), ) received = [] with (source_path or (RAW / "tx_stream.cs8")).open("rb") as stream: while block := stream.read(2 * receiver.block_samples): values = np.frombuffer(block, np.int8) samples = values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32) received.extend(receiver.push_iq_block(samples.astype(np.complex64))) received.extend(receiver.finish()) expected = [frame.plan.payload for frame in plans()] actual = [item.payload for item in received] result = { "mode": mode, "candidates": len(observations), "true_packets_found": len(actual), "header_pass": sum(outcome.phy_header_pass for _, outcome in observations), "fec_started": sum(outcome.fec_attempted for _, outcome in observations), "fec_pass": sum(outcome.fec_pass for _, outcome in observations), "crc_started": sum(outcome.crc != "NOT_REACHED" for _, outcome in observations), "crc_pass": sum(outcome.crc == "PASS" for _, outcome in observations), "packet_output": len(actual), "exact_payload_match": sum(payload in expected for payload in actual), "exact_ordered_payloads": actual == expected, "passed": len(actual) == PACKETS and actual == expected, } if diagnostics: payload_to_index = {payload: index + 1 for index, payload in enumerate(expected)} result["passed_packet_indexes"] = sorted( payload_to_index[payload] for payload in actual if payload in payload_to_index ) rows = [] for row, outcome in observations: payload = None if outcome.frame is not None and outcome.frame.reception.payload_bytes is not None: payload = bytes(outcome.frame.reception.payload_bytes) traces = row.get("carrier_tracking_traces", ()) final_frequency = None if traces and len(traces[-1].frequency_hz): final_frequency = float(traces[-1].frequency_hz[-1]) rows.append({ "absolute_refined": int(row["absolute_refined"]), "reason": outcome.reason, "header": bool(outcome.phy_header_pass), "fec_started": bool(outcome.fec_attempted), "fec": bool(outcome.fec_pass), "crc_started": outcome.crc != "NOT_REACHED", "crc": outcome.crc == "PASS", "packet_output": bool(outcome.accepted and outcome.crc == "PASS"), "packet_index_after_verdict": payload_to_index.get(payload), "initial_frequency_hz": row.get("cfo_hz"), "t2_final_frequency_hz": final_frequency, "t2_lock_status": "NOT_DEFINED_BY_IMPLEMENTATION" if mode == "t2" else "NOT_APPLICABLE", }) result["rows"] = rows result["detector_statistics"] = dict(receiver.detection_statistics) result["candidate_overflow"] = receiver.candidate_overflow return result def software_gate() -> dict: metadata = validate_prepared() legacy = decode("legacy") t2 = decode("t2") result = { "lab": "Lab062", "stage": "SOFTWARE_GATE", "rf_tx_count": 0, "tx_sha256": metadata["tx_sha256"], "legacy": legacy, "t2": t2, "passed": legacy["passed"] and t2["passed"], } save_new(OUTPUT / "software_gate.json", result) if not result["passed"]: raise RuntimeError("Lab062 software gate FAIL") return result def preflight(*, runner=None, process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict: metadata = validate_prepared() software = json.loads((OUTPUT / "software_gate.json").read_text(encoding="utf-8")) if not software.get("passed") or software.get("tx_sha256") != metadata["tx_sha256"]: raise RuntimeError("Нет действующего software gate") save_new(RAW / "preflight_attempt.json", { "lab": "Lab062", "rf_tx_count": 0, "point": asdict(POINT), "tx_sha256": metadata["tx_sha256"], }) actual_runner = runner or lab054.clock._default_command_runner process_checker = process_checker or lab054.check_processes clock_checker = clock_checker or hardware.common_clock def rx_only(command): if "-t" in command: raise PermissionError("TX запрещён в Lab062 preflight") return actual_runner(command) result = { "lab": "Lab062", "stage": "RX_ONLY_PREFLIGHT", "passed": False, "rf_tx_count": 0, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], "stop_before_tx": True, } clock_started = False try: before = process_checker() result["process_before"] = before.to_dict() lab054.require_clear(before) result["inventory"] = lab054.phy.lab045._verify_device_inventory(rx_only) clock_started = True result["clock"] = clock_checker( POINT, rx_only, lab054.clock.TransmitGuard(), sleep_function=sleep_function ) hardware.require_clock_pass(result["clock"]) path = RAW / "preflight_rx.cs8" if path.exists(): raise FileExistsError("Lab062 preflight RX уже существует") captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES)) result["rx_process"] = { "returncode": captured.returncode, "stdout": captured.stdout or "", "stderr": captured.stderr or "", } log = (captured.stdout or "") + (captured.stderr or "") if captured.returncode or not hardware.clean_counter_log(log, "overrun"): raise RuntimeError("RX preflight: процесс или счётчик overruns не прошёл") if path.stat().st_size != 2 * PREFLIGHT_SAMPLES: raise RuntimeError("Неверная длина Lab062 preflight RX") level = hardware.receive_level(path) # У RX-only фона нет символов. Унаследованная метрика Lab056 была бы # привязана к её 20 ksymbol/s и здесь физически неприменима. level.pop("symbol_energy_code2_seconds", None) level["symbol_energy"] = "NOT_APPLICABLE_TO_BACKGROUND" ac_rms = float(np.sqrt(max(0.0, level["ac_power_code2"]))) strong = bool(ac_rms >= STRONG_AC_RMS_CODE or level["max_abs_i"] >= STRONG_COMPONENT_CODE or level["max_abs_q"] >= STRONG_COMPONENT_CODE) level.update({ "ac_rms_code": ac_rms, "unexpected_strong_signal": strong, "strong_signal_rule": ( f"AC RMS >= {STRONG_AC_RMS_CODE} code or max |I|/|Q| >= " f"{STRONG_COMPONENT_CODE} code" ), }) result["rx_level"] = level result["rx_sha256"] = sha256(path) result["rx_overruns"] = 0 if level["saturation_detected"]: raise RuntimeError("RX clipping; STOP без изменения gain") if strong: raise RuntimeError("Неожиданно сильный RX-only фон; STOP без изменения gain") result["passed"] = True except Exception as error: result["error"] = str(error) raise finally: errors = lab054.disable_clock_outputs(rx_only) if clock_started else [] try: after = process_checker() result["process_after"] = after.to_dict() lab054.require_clear(after) except Exception as error: errors.append(str(error)) result["cleanup_errors"] = errors result["passed"] = result["passed"] and not errors save_new(RAW / "preflight_result.json", result) OUTPUT.mkdir(parents=True, exist_ok=True) (OUTPUT / "preflight_result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) if not result["passed"]: raise RuntimeError("Lab062 preflight cleanup FAIL") return result def require_preflight(metadata: dict) -> dict: """Проверить сохранённый preflight именно для подготовленного потока Lab062.""" record = json.loads((RAW / "preflight_result.json").read_text(encoding="utf-8")) if (record.get("passed") is not True or record.get("rf_tx_count") != 0 or record.get("point") != asdict(POINT) or record.get("parameters") != parameters() or record.get("tx_sha256") != metadata["tx_sha256"]): raise PermissionError("Нет успешного RX-only preflight для этой конфигурации Lab062") hardware.require_clock_pass(record.get("clock", {})) level = record.get("rx_level", {}) if (level.get("saturation_detected") is not False or level.get("unexpected_strong_signal") is not False or record.get("rx_overruns") != 0): raise PermissionError("RX-only preflight не разрешает передачу") return record def capture_once(*, authorization: bool = False, runner=None, process_starter=None, process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict: """Выполнить один RX-first сеанс и навсегда израсходовать попытку до обращения к SDR.""" if authorization is not True: raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab062") metadata = validate_prepared() require_preflight(metadata) receive_path = RAW / "rx_stream.cs8" if receive_path.exists() or (RAW / "capture_result.json").exists(): raise FileExistsError("Аппаратный результат Lab062 уже существует") actual_runner = runner or lab054.clock._default_command_runner process_starter = process_starter or lab054.clock._default_process_starter process_checker = process_checker or lab054.check_processes clock_checker = clock_checker or hardware.common_clock save_new(RAW / "hardware_attempt.json", { "lab": "Lab062", "authorized_once": True, "automatic_retry": False, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], }) result = { "lab": "Lab062", "stage": "SINGLE_RF_CAPTURE", "passed": False, "rf_tx_count": 0, "automatic_tx_repeats": 0, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], } guard = lab054.clock.TransmitGuard() process = None clock_started = False try: before = process_checker() result["process_before"] = before.to_dict() lab054.require_clear(before) result["inventory"] = lab054.phy.lab045._verify_device_inventory(actual_runner) clock_started = True result["clock"] = clock_checker( POINT, actual_runner, guard, sleep_function=sleep_function ) hardware.require_clock_pass(result["clock"]) process = process_starter( transfer_command(receive_path, RX_SAMPLES), RAW / "rx_stdout.txt", RAW / "rx_stderr.txt", ) sleep_function(RX_LEAD_SECONDS) if process.poll() is not None: raise RuntimeError("RX завершился до TX") before_tx = hardware.receive_level(receive_path, tail_samples=65536) before_tx.pop("symbol_energy_code2_seconds", None) result["rx_level_before_tx"] = before_tx if before_tx["saturation_detected"]: raise RuntimeError("RX clipping до TX; RF TX = 0") guard.register_transmit() result["rf_tx_count"] = guard.transmit_calls transmitted = actual_runner(transfer_command( RAW / "tx_stream.cs8", metadata["tx_samples"], transmit=True )) save_new(RAW / "tx_process_result.json", { "returncode": transmitted.returncode, "stdout": transmitted.stdout or "", "stderr": transmitted.stderr or "", }) if transmitted.returncode: raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён") if process.wait(RX_SECONDS + 10): raise RuntimeError("RX завершился ошибкой; передача уже выполнена, повтор запрещён") process.close_logs() result["rx_samples"] = receive_path.stat().st_size // 2 result["rx_bytes"] = receive_path.stat().st_size result["rx_duration_seconds"] = result["rx_samples"] / MODE.sample_rate_hz if result["rx_bytes"] != RX_SAMPLES * 2: raise RuntimeError("Повреждённая длина RX; передача уже выполнена, повтор запрещён") rx_log = "".join( (RAW / f"rx_{kind}.txt").read_text(encoding="utf-8") for kind in ("stdout", "stderr") ) tx_log = (transmitted.stdout or "") + (transmitted.stderr or "") if not hardware.clean_counter_log(rx_log, "overrun"): raise RuntimeError("RX overrun или неоднозначный счётчик; повтор запрещён") if not hardware.clean_counter_log(tx_log, "underrun"): raise RuntimeError("TX underrun или неоднозначный счётчик; повтор запрещён") result["rx_overruns"] = 0 result["tx_underruns"] = 0 result["rx_sha256"] = sha256(receive_path) level = hardware.receive_level(receive_path) level.pop("symbol_energy_code2_seconds", None) result["rx_level"] = level result["rx_clipping"] = bool(level["saturation_detected"]) if result["rx_clipping"]: raise RuntimeError("RX clipping в записи; передача уже выполнена, повтор запрещён") result["passed"] = True except Exception as error: result["error"] = str(error) raise finally: errors = [] if process is not None: try: process.terminate() except Exception as error: errors.append(str(error)) if clock_started: errors.extend(lab054.disable_clock_outputs(actual_runner)) try: after = process_checker() result["process_after"] = after.to_dict() lab054.require_clear(after) except Exception as error: errors.append(str(error)) result["cleanup_errors"] = errors result["passed"] = result["passed"] and not errors save_new(RAW / "capture_result.json", result) OUTPUT.mkdir(parents=True, exist_ok=True) (OUTPUT / "capture_result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) if not result["passed"]: raise RuntimeError("Lab062 RF-сеанс завершён с ошибкой; повтор запрещён") return result def run_functional_tests() -> None: assert MODE.samples_per_symbol == 16 assert len(plans()) == 16 assert all(len(frame.plan.payload) == 256 for frame in plans()) assert "-t" not in transfer_command("NUL", 65536) assert "-t" in transfer_command("NUL", 65536, transmit=True) def main() -> None: import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--stage", choices=("prepare", "software", "preflight", "capture"), required=True) parser.add_argument("--authorize-one-rf", action="store_true") arguments = parser.parse_args() if arguments.stage == "prepare": result = prepare() elif arguments.stage == "software": result = software_gate() elif arguments.stage == "preflight": result = preflight() else: result = capture_once(authorization=arguments.authorize_one_rf) print(json.dumps(result, ensure_ascii=False, indent=2)) print("RF TX = 0") if __name__ == "__main__": main()