Files
SDR-Rover/experiments/lab062_full_packet_rf_preflight.py

476 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()