Files
SDR-Rover/experiments/lab054_hardware_control.py

360 lines
17 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.
"""Lab054: подготовка короткого BPSK-контроля; RF только отдельной командой."""
from __future__ import annotations
import argparse
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 lab047_jpeg_object_transport as hardware
from experiments import lab049_bpsk_symbol_rate as phy
from experiments.lab053_packet_processing import bind
from experiments.lab053_streaming_receiver import StreamingReceiver
from tools.hackrf_process_check import check_processes, require_clear
clock = hardware.lab044.lab043
MODE = phy.PhyMode(500_000)
RF_HZ = 1_400_000_000
PACKETS = 16
RX_SECONDS = 3
RX_LEAD_SECONDS = 0.5
RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz
DIRECTORY = Path("data/raw/lab054")
OUTPUT = Path("data/processed/lab054")
def save(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
def plans():
"""Обычный бинарный объект, 16 фрагментов прежнего формата; не видеопоток."""
random_generator = np.random.default_rng(54054)
data = random_generator.integers(0, 256, PACKETS * phy.lab046.FRAGMENT_DATA_SIZE, dtype=np.uint8)
return phy.lab046.build_object_frames(data.tobytes(), object_id=54054)
def parameters() -> dict:
return {"rf_hz": RF_HZ, "sample_rate_hz": MODE.sample_rate_hz,
"symbol_rate": MODE.symbol_rate, "samples_per_symbol": MODE.samples_per_symbol,
"carrier_hz": phy.lab045.CARRIER_OFFSET_HZ,
"master_tx": clock.MASTER_SERIAL, "slave_rx": clock.SLAVE_SERIAL,
"tx_vga_db": 10, "rx_lna_db": 24, "rx_vga_db": 24,
"amplifier": False, "bias_tee": False,
"threshold": hardware.FIXED_HARDWARE_CANDIDATE_THRESHOLD}
def transfer_command(serial: str, path: Path | str, samples: int, *, transmit=False) -> list[str]:
command = [str(clock.HACKRF_TOOLS_DIR / "hackrf_transfer.exe"), "-d", serial,
"-t" if transmit else "-r", str(path), "-f", str(RF_HZ),
"-s", str(MODE.sample_rate_hz), "-n", str(samples)]
command += ["-x", "10"] if transmit else ["-l", "24", "-g", "24"]
return command + ["-a", "0", "-p", "0", "-B"]
def short_receive(serial, runner):
"""Штатный запуск приёмника для выбора опоры, без передающего режима."""
result = runner(transfer_command(serial, "NUL", clock.SHORT_RX_SAMPLE_COUNT))
if result.returncode:
raise RuntimeError(f"Короткий RX не выполнен: {result.stderr}")
def disable_clock_outputs(runner) -> list[str]:
"""Попытаться отключить обе платы даже при timeout первой команды."""
errors = []
for serial in (clock.MASTER_SERIAL, clock.SLAVE_SERIAL):
try:
clock._set_clkout(serial, False, runner)
except Exception as error:
errors.append(f"{serial}: {error}")
return errors
clock_preflight = bind(clock.common_clock_preflight, _short_rx=short_receive,
_disable_clock_outputs=disable_clock_outputs)
def preflight_only(directory: Path = OUTPUT, runner=None, process_checker=None) -> dict:
"""Реальный RX-only preflight; выходы тактирования выключаются в finally."""
runner = runner or clock._default_command_runner
process_checker = process_checker or check_processes
record = {"rf_tx_count": 0, "passed": False}
guard = clock.TransmitGuard()
clock_started = False
try:
process_state = process_checker()
record["process_check"] = process_state.to_dict()
require_clear(process_state)
record["inventory"] = phy.lab045._verify_device_inventory(runner)
clock_started = True
record["clock"] = clock_preflight(runner, guard)
record["passed"] = True
except Exception as error:
record["error"] = str(error)
raise
finally:
record["cleanup_errors"] = disable_clock_outputs(runner) if clock_started else []
record["cleanup_performed"] = clock_started
if clock_started:
try:
process_state = process_checker()
record["process_check_after_cleanup"] = process_state.to_dict()
require_clear(process_state)
except Exception as error:
record["cleanup_errors"].append(str(error))
record["passed"] = record["passed"] and not record["cleanup_errors"]
save(directory / "common_clock_preflight.json", record)
if not record["passed"]:
raise RuntimeError("Не удалось выключить CLKOUT")
return record
def prepare(directory: Path = DIRECTORY) -> dict:
directory.mkdir(parents=True, exist_ok=True)
path = directory / "tx_stream.cs8"
if path.exists():
raise FileExistsError("TX-файл уже существует; подготовка его не перезаписывает")
with path.open("xb") as stream:
for frame in plans():
stream.write(phy.build_parameterized_tx_cs8(frame.plan, MODE))
count = path.stat().st_size // 2
result = {"stage": "PREPARATION_ONLY", "rf_tx_count": 0,
"head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"parameters": parameters(), "packets": PACKETS, "tx_samples": count,
"tx_bytes": path.stat().st_size, "tx_seconds": count / MODE.sample_rate_hz,
"tx_sha256": hardware._sha256_file(path), "rx_seconds": RX_SECONDS,
"rx_samples": RX_SAMPLES, "rx_lead_seconds": RX_LEAD_SECONDS,
"rx_command": transfer_command(clock.SLAVE_SERIAL, directory / "rx_stream.cs8", RX_SAMPLES),
"tx_command": transfer_command(clock.MASTER_SERIAL, path, count, transmit=True)}
save(directory / "tx_metadata.json", result)
save(OUTPUT / "preparation.json", result)
return result
def validate_prepared(directory: Path) -> dict:
metadata = json.loads((directory / "tx_metadata.json").read_text(encoding="utf-8"))
path = directory / "tx_stream.cs8"
expected = hashlib.sha256()
count = 0
for frame in plans():
waveform = phy.build_parameterized_tx_cs8(frame.plan, MODE)
expected.update(waveform)
count += len(waveform)
if (metadata["parameters"] != parameters() or metadata["stage"] != "PREPARATION_ONLY"
or metadata["rf_tx_count"] != 0 or metadata["packets"] != PACKETS
or metadata["tx_samples"] * 2 != count or path.stat().st_size != count
or metadata["tx_sha256"] != expected.hexdigest()
or hardware._sha256_file(path) != expected.hexdigest()):
raise RuntimeError("TX-артефакты или параметры изменены")
return metadata
def decode_file(path: Path) -> tuple[dict, list]:
"""RX получает только CS8-файл; TX truth отсутствует в интерфейсе."""
observations = []
receiver = StreamingReceiver(packet_observer=lambda row, outcome: observations.append((row.copy(), outcome)))
received = []
clipping = 0
try:
with path.open("rb") as stream:
while block := stream.read(2 * 65536):
values = np.frombuffer(block, np.int8)
if len(values) % 2:
raise ValueError("Неполный IQ-отсчёт")
clipping += int(np.count_nonzero((values == -128) | (values == 127)))
samples = (values[::2].astype(np.float32) + 1j * values[1::2]).astype(np.complex64)
received.extend(receiver.push_iq_block(samples))
received.extend(receiver.finish())
finally:
if not receiver.closed:
receiver.finish()
rows = []
for row, outcome in observations:
frame = outcome.frame
rows.append({"accepted": outcome.accepted, "reason": outcome.reason,
"sync_pass": outcome.refinement_pass and outcome.timing_pass and outcome.carrier_pass,
"phy_header_pass": outcome.phy_header_pass, "fec_pass": outcome.fec_pass,
"crc": outcome.crc, "start": row["absolute_refined"],
"timing": frame.timing_offset_samples if frame else None,
"cfo_hz": frame.estimated_cfo_hz if frame else None,
"phase_rad": frame.estimated_phase_rad if frame else None,
"snr_db": frame.snr_db if frame else None,
"preamble_errors": frame.preamble_errors if frame else None})
return {"independent": True, "packets": len(received),
"frames_detected": len({r["start"] for r in rows if r["sync_pass"]}),
"frames_detected_definition": "Unique refined starts with refinement/timing/carrier PASS; may include false candidates",
"strict_candidates": len(rows), "phy_header_pass": sum(r["phy_header_pass"] for r in rows),
"fec_pass": sum(r["fec_pass"] for r in rows),
"crc_pass": sum(r["crc"] == phy.lab045.CRC_PASS for r in rows),
"crc_fail": sum(r["crc"] == phy.lab045.CRC_FAIL for r in rows),
"clipping_components": clipping, "rows": rows}, observations
def roundtrip(directory: Path = DIRECTORY) -> dict:
validate_prepared(directory)
result, observations = decode_file(directory / "tx_stream.cs8")
result.update(compare_after_verdict(observations))
result["passed"] = (result["packets"] == result["exact_packets"] == result["unique_exact_packets"] == PACKETS
and result["post_fec_errors"] == 0)
save(OUTPUT / "software_roundtrip.json", result)
if not result["passed"]:
raise RuntimeError("Программный roundtrip FAIL")
return result
def compare_after_verdict(observations) -> dict:
"""Сверка разрешена только после завершения независимого приёма."""
by_payload = {frame.plan.payload: frame.plan for frame in plans()}
comparisons = []
matched_payloads = set()
exact_count = 0
for _, outcome in observations:
if outcome.accepted and outcome.frame:
frame = outcome.frame
plan = by_payload.get(frame.reception.payload_bytes)
if plan is not None:
if frame.reception.packet_bytes == plan.packet:
matched_payloads.add(frame.reception.payload_bytes)
exact_count += 1
comparisons.append(phy.lab045.diagnostic_pre_and_post_fec(
frame.reception, frame.post_preamble_soft, plan))
return {"exact_packets": exact_count, "unique_exact_packets": len(matched_payloads),
"payload_matched_packets": len(comparisons),
"pre_fec_errors": sum(item["pre_fec_coded"]["bit_errors"] for item in comparisons) if comparisons else None,
"post_fec_errors": sum(item["post_fec_packet"]["bit_errors"] for item in comparisons) if comparisons else None,
"error_scope": "CRC accepted packets matched by payload AFTER independent verdict; unmatched packets UNKNOWN"}
def analyze_capture(directory: Path = DIRECTORY) -> dict:
"""Офлайн-анализ сохранённой записи, без повторной передачи."""
evidence = json.loads((directory / "capture_result.json").read_text(encoding="utf-8"))
path = directory / "rx_stream.cs8"
if evidence.get("rx_sha256") != hardware._sha256_file(path):
raise RuntimeError("Нет подтверждённого SHA записи")
result, observations = decode_file(path)
save(OUTPUT / "independent_result.json", result)
result.update(compare_after_verdict(observations))
result.update(frames_tx=PACKETS, rx_overruns="UNKNOWN_UNLESS_EXPLICIT_IN_LOG",
tx_underruns="UNKNOWN_UNLESS_EXPLICIT_IN_LOG")
result["result"] = "A" if (result["unique_exact_packets"] >= 15 and evidence["passed"]
and result["post_fec_errors"] == 0
and result["clipping_components"] == 0) else "NOT_A"
result["scope"] = "One short control only; no long-term stability or video RF claim"
save(OUTPUT / "hardware_result.json", result)
return result
def capture_once(directory: Path = DIRECTORY, *, authorization=False, runner=None,
process_starter=None, process_checker=None, sleep_function=time.sleep) -> dict:
"""Один конечный TX; эксклюзивный маркер запрещает повтор после любой ошибки."""
if authorization is not True:
raise PermissionError("Нужно отдельное разрешение пользователя на Lab054 RF")
metadata = validate_prepared(directory)
runner = runner or clock._default_command_runner
process_starter = process_starter or clock._default_process_starter
process_checker = process_checker or check_processes
with (directory / "hardware_attempt.json").open("x", encoding="utf-8") as stream:
json.dump({"authorized_once": True, "automatic_retry": False}, stream)
guard = clock.TransmitGuard()
process = None
clock_started = False
result = {"passed": False, "rf_tx_count": 0}
try:
process_state = process_checker()
result["process_check"] = process_state.to_dict()
require_clear(process_state)
result["inventory"] = phy.lab045._verify_device_inventory(runner)
clock_started = True
result["clock"] = clock_preflight(runner, guard)
receive_path = directory / "rx_stream.cs8"
process = process_starter(transfer_command(clock.SLAVE_SERIAL, receive_path, RX_SAMPLES),
directory / "rx_stdout.txt", directory / "rx_stderr.txt")
sleep_function(RX_LEAD_SECONDS)
if process.poll() is not None:
raise RuntimeError("RX завершился до TX")
guard.register_transmit()
result["rf_tx_count"] = guard.transmit_calls
transmitted = runner(transfer_command(clock.MASTER_SERIAL, directory / "tx_stream.cs8",
metadata["tx_samples"], transmit=True))
(directory / "tx_stdout.txt").write_text(transmitted.stdout or "", encoding="utf-8")
(directory / "tx_stderr.txt").write_text(transmitted.stderr or "", encoding="utf-8")
if transmitted.returncode or process.wait(RX_SECONDS + 10):
raise RuntimeError("Ошибка единственного TX/RX; повтор запрещён")
process.close_logs()
if receive_path.stat().st_size != RX_SAMPLES * 2:
raise RuntimeError("Неверный размер RX")
for role in ("rx", "tx"):
log = "".join((directory / f"{role}_{kind}.txt").read_text(encoding="utf-8") for kind in ("stdout", "stderr"))
if not clock._transfer_log_is_clean(log):
raise RuntimeError(f"{role}: overrun/underrun; STOP")
result.update(passed=True, rx_sha256=hardware._sha256_file(receive_path))
except Exception as error:
result["error"] = str(error)
raise
finally:
result["cleanup_errors"] = []
if process is not None:
try:
process.terminate()
except Exception as error:
result["cleanup_errors"].append(str(error))
if clock_started:
result["cleanup_errors"].extend(disable_clock_outputs(runner))
try:
process_state = process_checker()
result["process_check_after_cleanup"] = process_state.to_dict()
require_clear(process_state)
result["lingering_processes"] = []
except Exception as error:
result["cleanup_errors"].append(str(error))
result["lingering_processes"] = None
result["passed"] = result["passed"] and not result["cleanup_errors"] and not result["lingering_processes"]
save(directory / "capture_result.json", result)
if not result["passed"]:
raise RuntimeError("Очистка аппаратного сеанса не прошла; STOP")
return result
def run_functional_tests() -> None:
assert MODE.samples_per_symbol == 16
assert len(plans()) == PACKETS
assert "-t" not in transfer_command(clock.MASTER_SERIAL, "NUL", 65536)
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", "roundtrip", "preflight", "capture", "analyze"), required=True)
parser.add_argument("--authorize-one-rf", action="store_true")
args = parser.parse_args()
if args.stage == "prepare":
result = prepare()
elif args.stage == "roundtrip":
result = roundtrip()
elif args.stage == "preflight":
result = preflight_only()
elif args.stage == "analyze":
result = analyze_capture()
else:
result = capture_once(authorization=args.authorize_one_rf)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()