"""Lab064: лестница цифровых уровней и измерение линейности RF-тракта. Подготовка и анализ не используют пакетный приёмник. Аппаратный preflight работает только в режиме RX. Передача доступна отдельной командой с явным флагом и защищена одноразовым маркером. """ from __future__ import annotations from dataclasses import asdict import argparse import hashlib import json from pathlib import Path import re 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 lab043_pluto_to_rtlsdr as shaping from experiments import lab054_hardware_control as lab054 from experiments import lab055_energy_audit as energy from experiments import lab056_abab_energy as lab056 from experiments import lab056_capture_runner as hardware ROOT = Path(__file__).resolve().parents[1] RAW = ROOT / "data/raw/lab064" OUTPUT = ROOT / "data/processed/lab064" SAMPLE_RATE = 8_000_000 SYMBOL_RATE = 20_000 SAMPLES_PER_SYMBOL = SAMPLE_RATE // SYMBOL_RATE CARRIER_HZ = 50_000 NOMINAL_RMS_CODE = 44.0 LEVELS = (("L0", -18.0), ("L1", -12.0), ("L2", -6.0), ("L3", 0.0)) SEGMENT_SAMPLES = 800_000 GAP_SAMPLES = 400_000 LEADING_ZERO_SAMPLES = 800_000 TRAILING_ZERO_SAMPLES = 800_000 PRBS_SYMBOLS = SEGMENT_SAMPLES // SAMPLES_PER_SYMBOL - 42 TRIM_SAMPLES = 21 * SAMPLES_PER_SYMBOL TX_SAMPLES = ( LEADING_ZERO_SAMPLES + len(LEVELS) * SEGMENT_SAMPLES + (len(LEVELS) - 1) * GAP_SAMPLES + TRAILING_ZERO_SAMPLES ) RX_SECONDS = 2.0 RX_SAMPLES = int(RX_SECONDS * SAMPLE_RATE) RX_LEAD_SECONDS = 0.5 PREFLIGHT_SAMPLES = 800_000 STRONG_AC_RMS_CODE = 20.0 STRONG_COMPONENT_CODE = 100.0 BACKGROUND_WINDOW_SAMPLES = 160_000 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", ) def save_new(path: Path, value: dict) -> None: """Записать новый JSON и не перезаписывать свидетельства опыта.""" 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 save_text_new(path: Path, value: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("x", encoding="utf-8") as stream: stream.write(value) 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 parameters() -> dict: return { "rf_frequency_hz": POINT.frequency_hz, "sample_rate_hz": SAMPLE_RATE, "symbol_rate": SYMBOL_RATE, "samples_per_symbol": SAMPLES_PER_SYMBOL, "digital_carrier_hz": CARRIER_HZ, "rrc_rolloff": shaping.LAB042_RRC_ROLLOFF, "rrc_span_symbols": shaping.LAB042_RRC_SPAN_SYMBOLS, "prbs": "PRBS7 x^7+x^6+1, deterministic initial state", "full_phy_packet": False, "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", "levels_db_relative_nominal": [level for _, level in LEVELS], } def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]: return hardware.command(POINT, path, samples, transmit=transmit) def _complex_to_cs8(values: np.ndarray) -> np.ndarray: interleaved = np.empty(2 * len(values), dtype=np.int8) interleaved[::2] = np.clip(np.rint(values.real), -128, 127).astype(np.int8) interleaved[1::2] = np.clip(np.rint(values.imag), -128, 127).astype(np.int8) return interleaved def nominal_segment() -> np.ndarray: """Один участок длиной 100 мс с прежними PRBS7, BPSK и RRC.""" symbols = 2.0 * shaping.pilot_prbs7(PRBS_SYMBOLS) - 1.0 waveform = shaping.shape_frame_like_lab042(symbols, SAMPLES_PER_SYMBOL) if len(waveform) != SEGMENT_SAMPLES: raise RuntimeError("RRC-участок не равен заранее заданным 100 мс") carrier = np.exp(2j * np.pi * CARRIER_HZ * np.arange(len(waveform)) / SAMPLE_RATE) waveform = waveform * carrier waveform *= NOMINAL_RMS_CODE / np.sqrt(np.mean(np.abs(waveform) ** 2)) return waveform def level_segment(level_db: float) -> np.ndarray: scale = 10 ** (level_db / 20) return _complex_to_cs8(nominal_segment() * scale) def segment_metrics(interleaved: np.ndarray, level_db: float) -> dict: values = interleaved[::2].astype(np.float32) + 1j * interleaved[1::2].astype(np.float32) moments = energy.Moments() moments.add(values) result = moments.result(SYMBOL_RATE) result.update({ "requested_relative_power_db": level_db, "tx_rms_code": result["rms_code"], "tx_peak_abs_i_code": result["max_abs_i"], "tx_peak_abs_q_code": result["max_abs_q"], "tx_peak_magnitude_code": result["peak_magnitude_code"], "digital_clipping": bool(result["clipped_samples_fraction"] > 0), }) return result def prepared_segments() -> tuple[list[dict], dict[str, np.ndarray]]: cursor = LEADING_ZERO_SAMPLES rows: list[dict] = [] blocks: dict[str, np.ndarray] = {} for index, (label, level_db) in enumerate(LEVELS): block = level_segment(level_db) metrics = segment_metrics(block, level_db) rows.append({ "label": label, "level_db": level_db, "start_sample": cursor, "samples": SEGMENT_SAMPLES, "duration_seconds": SEGMENT_SAMPLES / SAMPLE_RATE, "analysis_start_sample": cursor + TRIM_SAMPLES, "analysis_samples": SEGMENT_SAMPLES - 2 * TRIM_SAMPLES, "metrics": metrics, }) blocks[label] = block cursor += SEGMENT_SAMPLES if index < len(LEVELS) - 1: cursor += GAP_SAMPLES if cursor + TRAILING_ZERO_SAMPLES != TX_SAMPLES: raise RuntimeError("Ошибка схемы TX waveform") nominal_power = rows[-1]["metrics"]["power_code2"] for row in rows: row["metrics"]["actual_relative_power_db"] = float( 10 * np.log10(row["metrics"]["power_code2"] / nominal_power) ) for lower, upper in zip(rows, rows[1:]): upper["tx_input_difference_from_previous_db"] = float( 10 * np.log10( upper["metrics"]["power_code2"] / lower["metrics"]["power_code2"] ) ) return rows, blocks def prepare() -> dict: """Создать единственный конечный TX-файл и его проверяемые метаданные.""" RAW.mkdir(parents=True, exist_ok=True) OUTPUT.mkdir(parents=True, exist_ok=True) tx_path = RAW / "tx_level_staircase.cs8" rows, blocks = prepared_segments() with tx_path.open("xb") as stream: stream.write(bytes(2 * LEADING_ZERO_SAMPLES)) for index, (label, _) in enumerate(LEVELS): stream.write(blocks[label].tobytes()) if index < len(LEVELS) - 1: stream.write(bytes(2 * GAP_SAMPLES)) stream.write(bytes(2 * TRAILING_ZERO_SAMPLES)) if tx_path.stat().st_size != 2 * TX_SAMPLES: raise RuntimeError("Неверная длина подготовленного TX") if any(row["metrics"]["digital_clipping"] for row in rows): raise RuntimeError("В подготовленном TX обнаружен цифровой clipping") steps = [row["tx_input_difference_from_previous_db"] for row in rows[1:]] if any(abs(step - 6.0) > 0.1 for step in steps): raise RuntimeError("Цифровая лестница не выдерживает шаг +6 дБ") result = { "lab": "Lab064", "stage": "PREPARATION_ONLY", "rf_tx_count": 0, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "parameters": parameters(), "segments": rows, "leading_zero_samples": LEADING_ZERO_SAMPLES, "gap_samples": GAP_SAMPLES, "trailing_zero_samples": TRAILING_ZERO_SAMPLES, "tx_samples": TX_SAMPLES, "tx_bytes": 2 * TX_SAMPLES, "tx_duration_seconds": TX_SAMPLES / SAMPLE_RATE, "tx_sha256": sha256(tx_path), "rx_samples": RX_SAMPLES, "rx_duration_seconds": RX_SECONDS, "rx_lead_seconds": RX_LEAD_SECONDS, "automatic_retry": False, "tx_command_prepared_not_run": transfer_command(tx_path, 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_level_staircase.cs8" rows, blocks = prepared_segments() digest = hashlib.sha256() digest.update(bytes(2 * LEADING_ZERO_SAMPLES)) for index, (label, _) in enumerate(LEVELS): digest.update(blocks[label].tobytes()) if index < len(LEVELS) - 1: digest.update(bytes(2 * GAP_SAMPLES)) digest.update(bytes(2 * TRAILING_ZERO_SAMPLES)) if ( metadata.get("lab") != "Lab064" or metadata.get("stage") != "PREPARATION_ONLY" or metadata.get("rf_tx_count") != 0 or metadata.get("parameters") != parameters() or metadata.get("segments") != rows or metadata.get("tx_samples") != TX_SAMPLES or path.stat().st_size != 2 * TX_SAMPLES or metadata.get("tx_sha256") != digest.hexdigest() or sha256(path) != digest.hexdigest() ): raise RuntimeError("Подготовленный Lab064 TX или параметры изменились") return metadata def difference_db(new: float, old: float) -> float: if new <= 0 or old <= 0: raise ValueError("Для разности в дБ нужны положительные мощности") return float(10 * np.log10(new / old)) def counter_observations(log: str) -> list[dict]: """Сохранить все промежуточные и итоговые счётчики потерь из лога.""" pattern = re.compile( r"(? dict: observations = counter_observations(log) selected = [row for row in observations if row["kind"] == expected_kind] unparsed = re.search( r"overrun|underrun", re.sub( r"(? dict: """Проверить метрики на известном линейном канале без аппаратуры.""" rows, blocks = prepared_segments() random_generator = np.random.default_rng(64064) measured = [] for row in rows: block = blocks[row["label"]] reference = block[::2].astype(np.float64) + 1j * block[1::2].astype(np.float64) reference = reference[TRIM_SAMPLES:-TRIM_SAMPLES] noise = random_generator.normal(0, 0.03, len(reference)) + 1j * random_generator.normal( 0, 0.03, len(reference) ) received = 0.37 * np.exp(0.41j) * reference + (0.8 - 0.3j) + noise fitted = energy.fit_channel(reference, received, SAMPLE_RATE) measured.append({ "label": row["label"], "matched_signal_power": fitted["heldout_signal_power"], "residual_power": fitted["heldout_residual_power"], "gain_abs": fitted["gain_abs"], }) step_errors = [] for index in range(1, len(rows)): tx_step = rows[index]["tx_input_difference_from_previous_db"] rx_step = difference_db( measured[index]["matched_signal_power"], measured[index - 1]["matched_signal_power"], ) step_errors.append(rx_step - tx_step) passed = max(abs(error) for error in step_errors) < 0.02 return { "lab": "Lab064", "stage": "SOFTWARE_LINEARITY_GATE", "rf_tx_count": 0, "channel_model": "known linear complex gain plus DC and deterministic Gaussian noise", "rows": measured, "linearity_errors_db": step_errors, "passed": passed, } def software_gate() -> dict: metadata = validate_prepared() result = synthetic_linearity_gate() result["tx_sha256"] = metadata["tx_sha256"] save_new(OUTPUT / "software_gate.json", result) if not result["passed"]: raise RuntimeError("Lab064 software gate FAIL") return result def locate_waveform(tx: energy.Cs8File, rx: energy.Cs8File) -> dict: """Найти всю лестницу по прореженной диагностической корреляции.""" stride = 32 def decimated(source: energy.Cs8File) -> np.ndarray: chunks = [ source.read(start, min(262_144, source.size - start))[::stride] for start in range(0, source.size, 262_144) ] return np.concatenate(chunks).astype(np.complex128) reference = decimated(tx) received = decimated(rx) if len(received) < len(reference): raise ValueError("RX короче TX waveform") reference -= reference.mean() received -= received.mean() reference_energy = float(np.sum(np.abs(reference) ** 2)) cumulative = np.concatenate(([0.0], np.cumsum(np.abs(received) ** 2))) window_energy = cumulative[len(reference):] - cumulative[:-len(reference)] time_axis = np.arange(len(reference)) * stride / SAMPLE_RATE best = (-1.0, 0, 0.0) for frequency_hz in np.arange(-3.0, 3.01, 0.25): rotated = reference * np.exp(2j * np.pi * frequency_hz * time_axis) correlation = signal.fftconvolve(received, np.conj(rotated[::-1]), mode="valid") scores = np.abs(correlation) / np.sqrt( np.maximum(reference_energy * window_energy, 1e-30) ) index = int(np.argmax(scores)) if scores[index] > best[0]: best = (float(scores[index]), index * stride, float(frequency_hz)) if best[0] < 0.05: raise RuntimeError("Лестница уровней не локализована; измерение UNKNOWN") return {"start_sample": best[1], "correlation": best[0], "coarse_cfo_hz": best[2], "stride": stride} def _window_metrics(values: np.ndarray) -> dict: moments = energy.Moments() moments.add(values) result = moments.result(SYMBOL_RATE) result["ac_rms_code"] = float(np.sqrt(max(0.0, result["ac_power_code2"]))) return result def background_windows() -> list[dict]: """Окна внутри всех нулевых участков, вдали от активных границ.""" starts = ( ("INITIAL_GUARD", 320_000), ("GAP_L0_L1", 1_760_000), ("GAP_L1_L2", 2_960_000), ("GAP_L2_L3", 4_160_000), ("FINAL_GUARD", 5_440_000), ) return [ { "label": label, "tx_relative_start_sample": start, "samples": BACKGROUND_WINDOW_SAMPLES, "duration_seconds": BACKGROUND_WINDOW_SAMPLES / SAMPLE_RATE, } for label, start in starts ] def summarize_background(rows: list[dict]) -> dict: powers = [row["metrics"]["ac_power_code2"] for row in rows] rms_values = [row["metrics"]["ac_rms_code"] for row in rows] variation_db = difference_db(max(powers), min(powers)) return { "ac_power_min_code2": min(powers), "ac_power_max_code2": max(powers), "ac_rms_min_code": min(rms_values), "ac_rms_max_code": max(rms_values), "maximum_to_minimum_ac_power_db": variation_db, "stability_rule_db": 3.0, "stable": variation_db <= 3.0, } def residual_spectral_diagnostics( reference: np.ndarray, received: np.ndarray, cfo_hz: float, complex_gain: dict, ) -> dict: """Оценить паразитные полосы после вычитания согласованной составляющей.""" rotated = reference * np.exp(2j * np.pi * cfo_hz * np.arange(len(reference)) / SAMPLE_RATE) rotated -= rotated.mean() centered_received = received - received.mean() gain = complex(complex_gain["real"], complex_gain["imag"]) residual = centered_received - gain * rotated frequencies, density = energy.spectrum(residual) half_width = 0.675 * SYMBOL_RATE bands = {} for harmonic in (1, 2, 3): center = harmonic * CARRIER_HZ bands[f"residual_band_h{harmonic}_power_code2"] = energy.band_power( frequencies, density, center - half_width, center + half_width ) bands["interpretation"] = ( "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" ) return bands def classify_linearity( rows: list[dict], comparisons: list[dict], background_summary: dict ) -> dict: errors = [row["linearity_error_db"] for row in comparisons] l3_ratio = rows[-1]["matched_to_residual_db"] clipping = any(row["full_iq"]["clipped_samples_fraction"] > 0 for row in rows) if clipping: case = "INVALID_RX_CLIPPING" elif comparisons[-1]["linearity_error_db"] <= -1.5: case = "COMPRESSION" elif ( max(abs(value) for value in errors) <= 1.0 and l3_ratio < 3.0 and background_summary["stable"] ): case = "LINEAR_BUT_WEAK" elif ( max(abs(value) for value in errors) <= 1.0 and l3_ratio >= 6.0 and background_summary["stable"] ): case = "HEALTHY_LINEAR_RANGE" else: case = "NOT_LOCALIZED" return { "case": case, "rules_fixed_before_rf": { "linear_max_abs_error_db": 1.0, "compression_upper_step_error_at_most_db": -1.5, "weak_l3_matched_to_residual_below_db": 3.0, "healthy_l3_matched_to_residual_at_least_db": 6.0, }, } def capture_validity(capture: dict) -> dict: """Не дать последнему нулевому счётчику скрыть промежуточную потерю.""" reasons = [] for role, key in (("TX", "tx_counter_summary"), ("RX", "rx_counter_summary")): summary = capture.get(key, {}) if summary.get("clean") is not True: reasons.append(f"{role}_COUNTER_NOT_CLEAN") if summary.get("maximum_count") != 0: reasons.append(f"{role}_MAXIMUM_COUNTER_NOT_ZERO") if capture.get("passed") is not True: reasons.append("CAPTURE_RUNNER_NOT_PASSED") if capture.get("rx_clipping") is not False: reasons.append("RX_CLIPPING_NOT_EXCLUDED") return {"valid": not reasons, "invalid_reasons": reasons} def analyze_capture() -> dict: """Проанализировать сохранённый RX только как известный измерительный сигнал.""" metadata = validate_prepared() rx_path = RAW / "rx_stream.cs8" capture = json.loads((RAW / "capture_result.json").read_text(encoding="utf-8")) if capture.get("rf_tx_count") != 1 or capture.get("rx_sha256") != sha256(rx_path): raise RuntimeError("Нет подтверждённой единственной RX-записи Lab064") tx = energy.Cs8File(RAW / "tx_level_staircase.cs8") rx = energy.Cs8File(rx_path) location = locate_waveform(tx, rx) background_rows = [] for window in background_windows(): rx_start = location["start_sample"] + window["tx_relative_start_sample"] background_rows.append({ **window, "rx_start_sample": rx_start, "metrics": _window_metrics(rx.read(rx_start, window["samples"])), }) background_by_label = {row["label"]: row for row in background_rows} preceding_background = { "L0": "INITIAL_GUARD", "L1": "GAP_L0_L1", "L2": "GAP_L1_L2", "L3": "GAP_L2_L3", } rows = [] for segment in metadata["segments"]: tx_start = segment["start_sample"] nominal_rx_start = location["start_sample"] + tx_start fitted = energy.analyze_rx( lab056.OffsetSource(tx, tx_start), rx, [nominal_rx_start], SEGMENT_SAMPLES, SAMPLES_PER_SYMBOL, )["frames"][0] actual_start = fitted["start"] full_values = rx.read(actual_start, SEGMENT_SAMPLES) active_reference = tx.read(tx_start + TRIM_SAMPLES, SEGMENT_SAMPLES - 2 * TRIM_SAMPLES) active_received = rx.read(actual_start + TRIM_SAMPLES, SEGMENT_SAMPLES - 2 * TRIM_SAMPLES) complex_gain = lab056.complex_gain(active_reference, active_received, fitted["cfo_hz"]) matched = fitted["heldout_signal_power"] residual = fitted["heldout_residual_power"] background = background_by_label[preceding_background[segment["label"]]] rows.append({ "label": segment["label"], "level_db": segment["level_db"], "rx_start_sample": actual_start, "alignment_adjustment_samples": fitted["alignment_adjustment"], "full_iq": _window_metrics(full_values), "background_label": background["label"], "background": background["metrics"], "background_window_start_sample": background["rx_start_sample"], "matched_signal_power": matched, "matched_signal_amplitude_rms": float(np.sqrt(matched)), "residual_power": residual, "residual_to_matched_amplitude_ratio": float(np.sqrt(residual / matched)), "matched_to_residual_db": difference_db(matched, residual), "complex_gain_h": complex_gain, "gain_abs": fitted["gain_abs"], "gain_power_abs_h_squared": fitted["gain_abs"] ** 2, "cfo_hz": fitted["cfo_hz"], "residual_spectrum": residual_spectral_diagnostics( active_reference, active_received, fitted["cfo_hz"], complex_gain ), }) comparisons = [] for index in range(1, len(rows)): tx_difference = metadata["segments"][index]["tx_input_difference_from_previous_db"] rx_difference = difference_db( rows[index]["matched_signal_power"], rows[index - 1]["matched_signal_power"] ) comparisons.append({ "comparison": f"{rows[index]['label']}/{rows[index - 1]['label']}", "tx_input_difference_db": tx_difference, "rx_matched_power_difference_db": rx_difference, "linearity_error_db": rx_difference - tx_difference, "tx_amplitude_ratio": float(10 ** (tx_difference / 20)), "rx_matched_amplitude_ratio": float(10 ** (rx_difference / 20)), }) total_tx_difference = difference_db( metadata["segments"][-1]["metrics"]["power_code2"], metadata["segments"][0]["metrics"]["power_code2"], ) total_rx_difference = difference_db( rows[-1]["matched_signal_power"], rows[0]["matched_signal_power"] ) total_comparison = { "comparison": "L3/L0", "tx_digital_power_difference_db": total_tx_difference, "rx_matched_power_difference_db": total_rx_difference, "total_linearity_error_db": total_rx_difference - total_tx_difference, "tx_amplitude_ratio": float(10 ** (total_tx_difference / 20)), "rx_matched_amplitude_ratio": float(10 ** (total_rx_difference / 20)), } background_summary = summarize_background(background_rows) validity = capture_validity(capture) classification = ( classify_linearity(rows, comparisons, background_summary) if validity["valid"] else { "case": "INVALID_CAPTURE", "invalid_reasons": validity["invalid_reasons"], "diagnostic_metrics_only": True, "a_b_c_classification_permitted": False, } ) result = { "lab": "Lab064", "stage": "OFFLINE_RF_LEVEL_LINEARITY_ANALYSIS", "packet_receiver_used": False, "location": location, "rows": rows, "background_windows": background_rows, "background_summary": background_summary, "comparisons": comparisons, "total_comparison": total_comparison, "classification": classification, "capture_valid": validity["valid"], "capture_invalid_reasons": validity["invalid_reasons"], "tx_underruns_maximum": capture.get("tx_counter_summary", {}).get("maximum_count"), "tx_underruns_final": capture.get("tx_counter_summary", {}).get("final_count"), "rx_overruns_maximum": capture.get("rx_counter_summary", {}).get("maximum_count"), "rx_overruns_final": capture.get("rx_counter_summary", {}).get("final_count"), "rx_clipping": any(row["full_iq"]["clipped_samples_fraction"] > 0 for row in rows), } save_new(OUTPUT / "rf_result.json", result) return result def preflight(*, runner=None, process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict: """Проверить common clock и 0,1 с RX-фона, не создавая TX-команду.""" 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("Нет действующего Lab064 software gate") save_new(RAW / "preflight_attempt.json", { "lab": "Lab064", "rf_tx_count": 0, "point": asdict(POINT), "parameters": parameters(), "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: list[str]): if "-t" in command: raise PermissionError("TX запрещён в Lab064 preflight") return actual_runner(command) result = { "lab": "Lab064", "stage": "COMMON_CLOCK_AND_RX_ONLY_PREFLIGHT", "passed": False, "rf_tx_count": 0, "stop_before_tx": True, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], } 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("Lab064 preflight RX уже существует") captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES)) stdout = captured.stdout or "" stderr = captured.stderr or "" result["rx_process"] = {"returncode": captured.returncode, "stdout": stdout, "stderr": stderr} result["rx_counter_summary"] = 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("Неверная длина Lab064 preflight RX") level = hardware.receive_level(path) level.pop("symbol_energy_code2_seconds", None) level["symbol_energy"] = "NOT_APPLICABLE_TO_BACKGROUND" level["ac_rms_code"] = float(np.sqrt(max(0.0, level["ac_power_code2"]))) level["unexpected_strong_signal"] = bool( level["ac_rms_code"] >= STRONG_AC_RMS_CODE or level["max_abs_i"] >= STRONG_COMPONENT_CODE or level["max_abs_q"] >= STRONG_COMPONENT_CODE ) level["strong_signal_rule"] = ( f"AC RMS >= {STRONG_AC_RMS_CODE} code or max |I|/|Q| >= {STRONG_COMPONENT_CODE} code" ) result["rx_level"] = level result["rx_sha256"] = sha256(path) result["rx_overruns"] = result["rx_counter_summary"]["final_count"] if level["saturation_detected"]: raise RuntimeError("RX clipping; STOP без изменения gain") if level["unexpected_strong_signal"]: 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("Lab064 preflight cleanup 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 для этой конфигурации Lab064") 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-сеанс Lab064") metadata = validate_prepared() require_preflight(metadata) receive_path = RAW / "rx_stream.cs8" if receive_path.exists() or (RAW / "capture_result.json").exists(): raise FileExistsError("Аппаратный результат Lab064 уже существует") 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": "Lab064", "authorized_once": True, "automatic_retry": False, "point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"], }) result = { "lab": "Lab064", "stage": "SINGLE_RF_LEVEL_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=65_536) 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_level_staircase.cs8", TX_SAMPLES, transmit=True) ) tx_stdout = transmitted.stdout or "" tx_stderr = transmitted.stderr or "" save_text_new(RAW / "tx_stdout.txt", tx_stdout) save_text_new(RAW / "tx_stderr.txt", tx_stderr) result["tx_process"] = { "returncode": transmitted.returncode, "stdout_file": "tx_stdout.txt", "stderr_file": "tx_stderr.txt", } result["tx_counter_summary"] = 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() result["rx_samples"] = receive_path.stat().st_size // 2 result["rx_bytes"] = receive_path.stat().st_size result["rx_duration_seconds"] = result["rx_samples"] / SAMPLE_RATE if result["rx_bytes"] != 2 * RX_SAMPLES: raise RuntimeError("Повреждённая длина RX; повтор запрещён") rx_stdout = (RAW / "rx_stdout.txt").read_text(encoding="utf-8") rx_stderr = (RAW / "rx_stderr.txt").read_text(encoding="utf-8") result["rx_counter_summary"] = counter_summary(rx_stdout + rx_stderr, "overrun") 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 not result["tx_counter_summary"]["clean"]: raise RuntimeError("TX underrun или неоднозначный счётчик; результат сохранён, повтор запрещён") if not result["rx_counter_summary"]["clean"]: raise RuntimeError("RX overrun или неоднозначный счётчик; результат сохранён, повтор запрещён") 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, allow_nan=False) + "\n", encoding="utf-8", ) if not result["passed"]: raise RuntimeError("Lab064 RF-сеанс завершён с ошибкой; повтор запрещён") return result def run_functional_tests() -> None: assert SAMPLES_PER_SYMBOL == 400 assert PRBS_SYMBOLS == 1958 assert TX_SAMPLES == 6_000_000 rows, _ = prepared_segments() assert len(rows) == 4 assert all(not row["metrics"]["digital_clipping"] for row in rows) assert max(abs(row["tx_input_difference_from_previous_db"] - 6.0) for row in rows[1:]) < 0.1 assert synthetic_linearity_gate()["passed"] assert "-t" not in transfer_command("NUL", 65_536) 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"), 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) else: result = analyze_capture() print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)) print("RF TX = 0") if __name__ == "__main__": main()