Lab056: compare 20 and 500 ksymbol/s in controlled RF session
This commit is contained in:
359
experiments/lab054_hardware_control.py
Normal file
359
experiments/lab054_hardware_control.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user