"""Lab066: исправленный повтор полного 500K PHY-сеанса. Подготовка, программная проверка и preflight не передают RF. Единственный аппаратный сеанс доступен только отдельной командой с явным разрешением. """ from __future__ import annotations from dataclasses import asdict import argparse import csv 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 scipy import signal 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_capture_runner as hardware from experiments import lab058_abab_symbol_quality as lab058 from experiments import lab064_rf_level_linearity as lab064 ROOT = Path(__file__).resolve().parents[1] RAW = ROOT / "data/raw/lab066" OUTPUT = ROOT / "data/processed/lab066" 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 PACKET_SAMPLES = 92_256 INTER_PACKET_GAP_SECONDS = 0.005 INTER_PACKET_GAP_SAMPLES = 40_000 NUMBER_OF_GAPS = PACKETS - 1 GUARD_SECONDS = 0.1 GUARD_SAMPLES = 800_000 BASE_TX_SAMPLES = PACKETS * PACKET_SAMPLES + NUMBER_OF_GAPS * INTER_PACKET_GAP_SAMPLES EXPECTED_TX_SAMPLES = BASE_TX_SAMPLES + 2 * GUARD_SAMPLES PREFLIGHT_SAMPLES = 800_000 RX_SECONDS = 2 RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz RX_LEAD_SECONDS = 0.5 TX_VGA_DB = 20 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, allow_nan=False) stream.write("\n") 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() payloads = [frame.plan.payload for frame in result] if len(result) != PACKETS or any(len(payload) != PAYLOAD_BYTES for payload in payloads): raise RuntimeError("Штатный генератор не дал 16 payload по 256 байт") if len(set(payloads)) != PACKETS: raise RuntimeError("Payload пакетов должны однозначно различаться") 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": TX_VGA_DB, "rx_lna_db": 24, "rx_vga_db": 24, "rf_amplifier": False, "bias_tee": False, "geometry": asdict(POINT), "geometry_unchanged": True, "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", } def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]: command = hardware.command(POINT, path, samples, transmit=transmit) if transmit: command[command.index("-x") + 1] = str(TX_VGA_DB) return command def build_waveform() -> tuple[bytes, list[dict]]: """Собрать финальный поток с нулевыми guards и паузами.""" blocks: list[bytes] = [bytes(2 * GUARD_SAMPLES)] layout: list[dict] = [] cursor = GUARD_SAMPLES for index, frame in enumerate(plans(), start=1): packet = phy.build_parameterized_tx_cs8(frame.plan, MODE) if len(packet) != 2 * PACKET_SAMPLES: raise RuntimeError(f"Пакет {index}: ожидалось {PACKET_SAMPLES} отсчётов") layout.append({ "packet_index": index, "start_sample": cursor, "samples": PACKET_SAMPLES, "payload_sha256": hashlib.sha256(frame.plan.payload).hexdigest(), }) blocks.append(packet) cursor += PACKET_SAMPLES if index != PACKETS: blocks.append(bytes(2 * INTER_PACKET_GAP_SAMPLES)) layout[-1]["following_gap_start_sample"] = cursor layout[-1]["following_gap_samples"] = INTER_PACKET_GAP_SAMPLES cursor += INTER_PACKET_GAP_SAMPLES blocks.append(bytes(2 * GUARD_SAMPLES)) waveform = b"".join(blocks) if len(waveform) != 2 * EXPECTED_TX_SAMPLES or cursor + GUARD_SAMPLES != EXPECTED_TX_SAMPLES: raise RuntimeError("Неверная длина итогового TX-потока Lab066") return waveform, layout 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("Lab066 TX уже существует; перезапись запрещена") waveform, layout = build_waveform() waveform_path.write_bytes(waveform) result = { "lab": "Lab066", "stage": "PREPARATION_ONLY", "rf_tx_count": 0, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "parameters": parameters(), "packet_samples": PACKET_SAMPLES, "inter_packet_gap_seconds": INTER_PACKET_GAP_SECONDS, "inter_packet_gap_samples": INTER_PACKET_GAP_SAMPLES, "number_of_gaps": NUMBER_OF_GAPS, "zero_guard_before_samples": GUARD_SAMPLES, "zero_guard_after_samples": GUARD_SAMPLES, "base_tx_samples": BASE_TX_SAMPLES, "expected_tx_samples": EXPECTED_TX_SAMPLES, "tx_samples": EXPECTED_TX_SAMPLES, "tx_bytes": len(waveform), "tx_duration_seconds": EXPECTED_TX_SAMPLES / MODE.sample_rate_hz, "tx_sha256": sha256(waveform_path), "layout": layout, "rx_samples": RX_SAMPLES, "rx_duration_seconds": RX_SECONDS, "automatic_retry": False, "tx_command_prepared_not_run": transfer_command(waveform_path, EXPECTED_TX_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, layout = build_waveform() expected_digest = hashlib.sha256(expected).hexdigest() actual = path.read_bytes() if ( metadata.get("lab") != "Lab066" or metadata.get("stage") != "PREPARATION_ONLY" or metadata.get("rf_tx_count") != 0 or metadata.get("parameters") != parameters() or metadata.get("layout") != layout or metadata.get("expected_tx_samples") != EXPECTED_TX_SAMPLES or metadata.get("tx_samples") != EXPECTED_TX_SAMPLES or len(actual) != 2 * EXPECTED_TX_SAMPLES or metadata.get("tx_sha256") != expected_digest or hashlib.sha256(actual).hexdigest() != expected_digest or actual != expected ): raise RuntimeError("Подготовленный Lab066 TX или его разметка изменились") return metadata def decode(mode: str, source_path: Path | None = None, *, diagnostics: bool = False) -> dict: """Строго декодировать весь поток; legacy и T2 запускаются отдельно.""" 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] payload_to_index = {payload: index for index, payload in enumerate(expected, start=1)} result = { "mode": mode, "candidates": len(observations), "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 payload_to_index for payload in actual), "packet_indexes": [payload_to_index.get(payload) for payload in actual], "exact_ordered_payloads": actual == expected, "t2_state_scope": "NEW_PREAMBLE_ESTIMATE_PER_PACKET" if mode == "t2" else "NOT_APPLICABLE", "fallback_between_modes": False, "passed": len(actual) == PACKETS and actual == expected, } if diagnostics: result["rows"] = [{ "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( bytes(outcome.frame.reception.payload_bytes) if outcome.frame is not None and outcome.frame.reception.payload_bytes is not None else b"" ), "t2_tracking_runs_for_candidate": len(row.get("carrier_tracking_traces", ())), } for row, outcome in observations] result["detector_statistics"] = dict(receiver.detection_statistics) result["candidate_overflow"] = receiver.candidate_overflow if mode == "t2": result["t2_state_reset_evidence"] = { "estimate_source": "each candidate's own preamble", "context_scope": "new PacketMeasure context for every candidate", "tracking_runs_per_accepted_packet": [ row["t2_tracking_runs_for_candidate"] for row in result["rows"] if row["packet_output"] ], "all_accepted_packets_have_fresh_tracking_runs": all( row["t2_tracking_runs_for_candidate"] > 0 for row in result["rows"] if row["packet_output"] ), "persistent_tracking_state_in_packet_processor": False, } return result def software_gate() -> dict: metadata = validate_prepared() legacy = decode("legacy", diagnostics=True) t2 = decode("t2", diagnostics=True) result = { "lab": "Lab066", "stage": "SOFTWARE_GATE", "rf_tx_count": 0, "tx_sha256": metadata["tx_sha256"], "legacy": legacy, "t2": t2, "gaps_verified_zero": True, "t2_state_reset_verified_by_fresh_preamble_per_packet": bool( t2.get("t2_state_reset_evidence", {}).get( "all_accepted_packets_have_fresh_tracking_runs" ) ), "passed": ( legacy["passed"] and t2["passed"] and t2.get("t2_state_reset_evidence", {}).get( "all_accepted_packets_have_fresh_tracking_runs" ) is True ), } save_new(OUTPUT / "software_gate.json", result) if not result["passed"]: raise RuntimeError("Lab066 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 Lab066") save_new(RAW / "preflight_attempt.json", { "lab": "Lab066", "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 запрещён в Lab066 preflight") return actual_runner(command) result = { "lab": "Lab066", "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("Lab066 preflight RX уже существует") captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES)) stdout, stderr = captured.stdout or "", captured.stderr or "" result["rx_process"] = {"returncode": captured.returncode, "stdout": stdout, "stderr": stderr} result["rx_counter_summary"] = lab064.counter_summary(stdout + stderr, "overrun") if captured.returncode or not result["rx_counter_summary"]["clean"]: raise RuntimeError("RX preflight: процесс или максимум overruns не прошёл") if path.stat().st_size != 2 * PREFLIGHT_SAMPLES: raise RuntimeError("Неверная длина Lab066 preflight RX") level = hardware.receive_level(path) 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}) result["rx_level"] = level result["rx_sha256"] = sha256(path) result["rx_overruns"] = result["rx_counter_summary"]["maximum_count"] 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, allow_nan=False) + "\n", encoding="utf-8" ) if not result["passed"]: raise RuntimeError("Lab066 preflight FAIL") return result def require_preflight(metadata: dict) -> dict: 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 для конфигурации Lab066") hardware.require_clock_pass(record.get("clock", {})) if (record.get("rx_overruns") != 0 or record.get("rx_level", {}).get("saturation_detected") is not False or record.get("rx_level", {}).get("unexpected_strong_signal") is not False): 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 сеанс после отдельного разрешения.""" if authorization is not True: raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab066") metadata = validate_prepared() require_preflight(metadata) receive_path = RAW / "rx_stream.cs8" if receive_path.exists() or (RAW / "capture_result.json").exists(): raise FileExistsError("Аппаратный результат Lab066 уже существует") 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": "Lab066", "authorized_once": True, "automatic_retry": False, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], }) result = { "lab": "Lab066", "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, process, clock_started = lab054.clock.TransmitGuard(), None, 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=65_536) before_tx.pop("symbol_energy_code2_seconds", None) before_tx_ac_rms = float(np.sqrt(max(0.0, before_tx["ac_power_code2"]))) before_tx_strong = bool( before_tx_ac_rms >= STRONG_AC_RMS_CODE or before_tx["max_abs_i"] >= STRONG_COMPONENT_CODE or before_tx["max_abs_q"] >= STRONG_COMPONENT_CODE ) before_tx.update({ "ac_rms_code": before_tx_ac_rms, "unexpected_strong_signal": before_tx_strong, }) result["rx_level_before_tx"] = before_tx if before_tx["saturation_detected"]: raise RuntimeError("RX clipping до TX; RF TX = 0") if before_tx_strong: raise RuntimeError("Неожиданно сильный фон непосредственно перед TX; RF TX = 0") guard.register_transmit(); result["rf_tx_count"] = guard.transmit_calls transmitted = actual_runner(transfer_command(RAW / "tx_stream.cs8", EXPECTED_TX_SAMPLES, transmit=True)) tx_stdout, tx_stderr = transmitted.stdout or "", transmitted.stderr or "" (RAW / "tx_stdout.txt").write_text(tx_stdout, encoding="utf-8") (RAW / "tx_stderr.txt").write_text(tx_stderr, encoding="utf-8") result["tx_process"] = {"returncode": transmitted.returncode, "stdout_file": "tx_stdout.txt", "stderr_file": "tx_stderr.txt"} result["tx_counter_summary"] = lab064.counter_summary(tx_stdout + tx_stderr, "underrun") if transmitted.returncode: raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён") if process.wait(RX_SECONDS + 10): raise RuntimeError("RX завершился ошибкой; повтор запрещён") process.close_logs() rx_log = (RAW / "rx_stdout.txt").read_text(encoding="utf-8") + (RAW / "rx_stderr.txt").read_text(encoding="utf-8") result["rx_counter_summary"] = lab064.counter_summary(rx_log, "overrun") result["rx_samples"] = receive_path.stat().st_size // 2 result["rx_sha256"] = sha256(receive_path) result["rx_level"] = hardware.receive_level(receive_path) result["rx_clipping"] = bool(result["rx_level"]["saturation_detected"]) result["passed"] = result["rx_samples"] == RX_SAMPLES validity = lab064.capture_validity(result) if not validity["valid"] or not result["passed"]: result["passed"] = False raise RuntimeError("INVALID_CAPTURE; запись сохранена, повтор запрещён") 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, allow_nan=False) + "\n", encoding="utf-8") if not result["passed"]: raise RuntimeError("Lab066 RF-сеанс невалиден; повтор запрещён") return result def locate_first_packet(tx: lab058.Cs8File, rx: lab058.Cs8File) -> dict: """Найти первый пакет после строгого verdict, не раскрывая границы декодеру.""" stride = MODE.samples_per_symbol reference = tx.read(GUARD_SAMPLES, PACKET_SAMPLES).astype(np.complex64) received = rx.read(0, rx.size).astype(np.complex64) reference = reference[::stride] - np.mean(reference[::stride]) received = received[::stride] - np.mean(received[::stride]) correlation = signal.correlate(received, reference, mode="valid", method="fft") coarse = int(np.argmax(abs(correlation))) * stride full_reference = tx.read(GUARD_SAMPLES, PACKET_SAMPLES).astype(np.complex128) full_reference -= np.mean(full_reference) starts = range(max(0, coarse - 2 * stride), min(rx.size - PACKET_SAMPLES, coarse + 2 * stride) + 1) scores = [] for start in starts: values = rx.read(start, PACKET_SAMPLES).astype(np.complex128) values -= np.mean(values) scores.append(abs(np.vdot(full_reference, values))) first_start = starts.start + int(np.argmax(scores)) return { "first_packet_rx_start_sample": first_start, "stream_rx_start_sample": first_start - GUARD_SAMPLES, "coarse_first_packet_start_sample": coarse, "method": "first known packet correlation after strict receiver verdict", } def physical_packet_diagnostics(tx: lab058.Cs8File, rx: lab058.Cs8File, location: dict) -> dict: """Оценить 16 пакетов известным TX только после строгого решения.""" rows = [] stream_start = location["stream_rx_start_sample"] for layout, frame in zip(validate_prepared()["layout"], plans()): packet_index = layout["packet_index"] tx_start = layout["start_sample"] rx_start = stream_start + tx_start transmitted = tx.read(tx_start, PACKET_SAMPLES) received = rx.read(rx_start, PACKET_SAMPLES) expected = 2.0 * frame.plan.frame_bits.astype(np.float64) - 1.0 metrics, _working = lab058.analyze_values(received, expected, MODE.symbol_rate) channel, _arrays = lab058.channel_metrics( transmitted, received, MODE.symbol_rate, metrics["oracle"]["cfo_hz"] ) oracle = metrics["oracle"]["metrics"] rows.append({ "packet_index": packet_index, "tx_start_sample": tx_start, "rx_start_sample": rx_start, "symbol_error_rate": oracle["errors"] / oracle["symbols"], "symbol_rms_deviation": float(np.sqrt( oracle["residual_power"] / oracle["matched_component_power"] )), "matched_component_power": oracle["matched_component_power"], "residual_power": oracle["residual_power"], "matched_residual_db": float(10 * np.log10( oracle["matched_component_power"] / oracle["residual_power"] )), "band_gain_nonuniformity_p95_p05_db": channel["amplitude_ripple_p95_p05_db"], }) return { "method": "Lab058 oracle diagnostics per packet after strict verdict", "rows": rows, "comparison": { "lab056_B_oracle_symbol_error_rate": 0.03495896, "lab056_B_oracle_evm_rms": 0.845350, "lab062_source": "data/processed/lab062/rf_analysis.json", "scope": "Lab056 B and Lab062 are contextual comparisons, not identical estimands", }, } def summarize_analysis(result: dict) -> dict: """Свести строгие и физические результаты без повторного декодирования.""" legacy, t2 = result["run_a_legacy"], result["run_b_t2"] legacy_set, t2_set = set(legacy["packet_indexes"]), set(t2["packet_indexes"]) layout = validate_prepared()["layout"] stream_start = result["location_after_strict_runs"]["stream_rx_start_sample"] packet_rows = [] for layout_row in layout: index = layout_row["packet_index"] nominal = stream_start + layout_row["start_sample"] modes = {} for label, strict, accepted in ( ("legacy", legacy, index in legacy_set), ("t2", t2, index in t2_set) ): candidates = [ row for row in strict["rows"] if abs(row["absolute_refined"] - nominal) <= 64 ] best = max(candidates, key=lambda row: ( row["packet_output"], row["crc"], row["fec"], row["header"], -abs(row["absolute_refined"] - nominal), )) if candidates else None modes[label] = { "candidate_at_expected_packet": best is not None, "header": accepted or bool(best and best["header"]), "fec": accepted or bool(best and best["fec"]), "crc": accepted or bool(best and best["crc"]), "packet_output": accepted, } packet_rows.append({"packet_index": index, **modes}) physical_rows = result["physical_diagnostics"]["rows"] matched = float(np.mean([row["matched_component_power"] for row in physical_rows])) residual = float(np.mean([row["residual_power"] for row in physical_rows])) physical = { "symbol_error_rate": float(np.mean([row["symbol_error_rate"] for row in physical_rows])), "rms_symbol_deviation": float(np.sqrt(np.mean([ row["symbol_rms_deviation"] ** 2 for row in physical_rows ]))), "matched_component_power": matched, "residual_power": residual, "matched_residual_db": float(10 * np.log10(matched / residual)), "band_gain_nonuniformity_p95_p05_db_median": float(np.median([ row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows ])), "band_gain_nonuniformity_p95_p05_db_min": float(np.min([ row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows ])), "band_gain_nonuniformity_p95_p05_db_max": float(np.max([ row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows ])), } lab056 = { "symbol_error_rate": 0.03495896464646465, "rms_symbol_deviation": 0.8453497751219741, "matched_residual_db": 1.4648455366051558, "band_gain_nonuniformity_db": 5.471381501203027, } lab062_result = json.loads( (ROOT / "data/processed/lab062/rf_analysis.json").read_text(encoding="utf-8") )["lab058_quality"] lab062 = { "symbol_error_rate": lab062_result["symbol_error_rate"], "rms_symbol_deviation": lab062_result["evm_rms"], "matched_residual_db": lab062_result["matched_residual_db"], "band_gain_nonuniformity_db": lab062_result[ "band_gain_nonuniformity_p95_p05_db_median" ], "capture_status": "RF_SESSION_INVALID_TX_UNDERRUN", } def differences(reference: dict) -> dict: return { "symbol_error_rate_absolute": ( physical["symbol_error_rate"] - reference["symbol_error_rate"] ), "rms_symbol_deviation_absolute": ( physical["rms_symbol_deviation"] - reference["rms_symbol_deviation"] ), "matched_residual_db": ( physical["matched_residual_db"] - reference["matched_residual_db"] ), "band_gain_nonuniformity_db": ( physical["band_gain_nonuniformity_p95_p05_db_median"] - reference["band_gain_nonuniformity_db"] ), } return { "lab": "Lab066", "capture_valid": result["capture_valid"], "legacy": {key: legacy[key] for key in ( "candidates", "header_pass", "fec_started", "fec_pass", "crc_started", "crc_pass", "packet_output", "exact_payload_match", "packet_indexes", )}, "t2": {key: t2[key] for key in ( "candidates", "header_pass", "fec_started", "fec_pass", "crc_started", "crc_pass", "packet_output", "exact_payload_match", "packet_indexes", )}, "passed_by_both": sorted(legacy_set & t2_set), "passed_only_by_legacy": sorted(legacy_set - t2_set), "passed_only_by_t2": sorted(t2_set - legacy_set), "failed_by_both": sorted(set(range(1, PACKETS + 1)) - legacy_set - t2_set), "packet_rows": packet_rows, "physical": physical, "lab056_B": lab056, "difference_from_lab056_B": differences(lab056), "lab062": lab062, "difference_from_lab062": differences(lab062), "classification": "PARTIAL_PACKET_RECOVERY_NO_T2_GAIN", "primary_result": ( "Аппаратно валидный Lab066 дал одинаковые 10/16 точных пакетов " "в legacy и T2; физическое качество лучше Lab056 B и невалидной Lab062." ), "recommended_next_single_step": ( "Отдельно разрешить офлайн-сравнение header-ошибок пакетов " "1-3, 9, 15-16 с принятыми пакетами по сохранённому RX; новый TX не нужен." ), } def write_summary_outputs(result: dict) -> dict: summary = summarize_analysis(result) save_new(OUTPUT / "rf_summary.json", summary) with (OUTPUT / "packet_comparison.csv").open("x", newline="", encoding="utf-8-sig") as stream: writer = csv.writer(stream) writer.writerow([ "packet_index", "legacy_header", "legacy_fec", "legacy_crc", "legacy_packet_output", "t2_header", "t2_fec", "t2_crc", "t2_packet_output", ]) for row in summary["packet_rows"]: writer.writerow([ row["packet_index"], row["legacy"]["header"], row["legacy"]["fec"], row["legacy"]["crc"], row["legacy"]["packet_output"], row["t2"]["header"], row["t2"]["fec"], row["t2"]["crc"], row["t2"]["packet_output"], ]) return summary def analyze_saved_capture() -> dict: """Строго обработать одну будущую RX-запись обоими режимами.""" metadata = validate_prepared() capture = json.loads((RAW / "capture_result.json").read_text(encoding="utf-8")) rx_path = RAW / "rx_stream.cs8" if ( capture.get("lab") != "Lab066" or capture.get("rf_tx_count") != 1 or capture.get("automatic_tx_repeats") != 0 or capture.get("point") != asdict(POINT) or capture.get("parameters") != parameters() or capture.get("tx_sha256") != metadata["tx_sha256"] or capture.get("rx_sha256") != sha256(rx_path) ): raise RuntimeError("Нет подтверждённой единственной RX-записи Lab066") validity = lab064.capture_validity(capture) if not validity["valid"]: result = { "lab": "Lab066", "stage": "STRICT_DUAL_OFFLINE_ANALYSIS", "classification": "INVALID_CAPTURE", "capture_valid": False, "capture_invalid_reasons": validity["invalid_reasons"], "rf_tx_count": capture.get("rf_tx_count"), "automatic_tx_repeats": 0, } save_new(OUTPUT / "rf_result.json", result) return result legacy = decode("legacy", rx_path, diagnostics=True) t2 = decode("t2", rx_path, diagnostics=True) strict_runs_complete = ( legacy.get("candidate_overflow") == 0 and t2.get("candidate_overflow") == 0 and isinstance(legacy.get("rows"), list) and isinstance(t2.get("rows"), list) ) if not strict_runs_complete: raise RuntimeError("Строгие legacy/T2 прогоны не завершены; oracle-диагностика запрещена") tx = lab058.Cs8File(RAW / "tx_stream.cs8") rx = lab058.Cs8File(rx_path) location = locate_first_packet(tx, rx) result = { "lab": "Lab066", "stage": "STRICT_DUAL_OFFLINE_ANALYSIS", "tx_sha256": metadata["tx_sha256"], "rx_sha256": capture["rx_sha256"], "capture_valid": validity["valid"], "capture_invalid_reasons": validity["invalid_reasons"], "run_a_legacy": legacy, "run_b_t2": t2, "same_rx_capture": True, "fallback_between_modes": False, "strict_runs_complete": True, "location_after_strict_runs": location, "physical_diagnostics_scope": "KNOWN_TX_ONLY_AFTER_COMPLETE_STRICT_RUNS", "physical_diagnostics": physical_packet_diagnostics(tx, rx, location), "comparison_targets": ["Lab056 B", "Lab062", "Lab066"], } save_new(OUTPUT / "rf_result.json", result) return result def summarize_saved_analysis() -> dict: return write_summary_outputs(json.loads( (OUTPUT / "rf_result.json").read_text(encoding="utf-8") )) def run_functional_tests() -> None: assert EXPECTED_TX_SAMPLES == 3_676_096 assert transfer_command("NUL", 100, transmit=True)[-7:-5] == ["-x", "20"] assert "-t" not in transfer_command("NUL", 100) try: capture_once(authorization=False) except PermissionError: pass else: raise AssertionError("RF без отдельного разрешения") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--stage", choices=("prepare", "software", "preflight", "capture", "analyze", "summarize"), 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() elif arguments.stage == "capture": result = capture_once(authorization=arguments.authorize_one_rf) elif arguments.stage == "analyze": result = analyze_saved_capture() else: result = summarize_saved_analysis() print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)) print("RF TX NOW = 0") if __name__ == "__main__": main()