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()
|
||||
312
experiments/lab056_abab_energy.py
Normal file
312
experiments/lab056_abab_energy.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Lab056: программная подготовка ABAB и диагностика сохранённого RX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
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 lab055_energy_audit as energy
|
||||
|
||||
SAMPLE_RATE = 8_000_000
|
||||
CARRIER = 50_000
|
||||
GAP_SAMPLES = 800_000
|
||||
RX_SAMPLES = 24_000_000
|
||||
RX_LEAD_SECONDS = .5
|
||||
RAW_DIRECTORY = energy.PROJECT_ROOT / "data/raw/lab056"
|
||||
OUTPUT = energy.PROJECT_ROOT / "data/processed/lab056"
|
||||
|
||||
|
||||
def save_new(path: Path, value: dict) -> None:
|
||||
text = json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("x", encoding="utf-8") as stream:
|
||||
stream.write(text)
|
||||
|
||||
|
||||
def build_burst(symbol_rate: int) -> np.ndarray:
|
||||
"""Прежние PRBS7/BPSK/RRC, 204,8 мс символов, RMS всего burst=44."""
|
||||
if symbol_rate not in (20000, 500000):
|
||||
raise ValueError("Разрешены только два исследуемых режима")
|
||||
symbols = 2. * shaping.pilot_prbs7(4096 * (symbol_rate // 20000)) - 1.
|
||||
samples_per_symbol = SAMPLE_RATE // symbol_rate
|
||||
waveform = shaping.shape_frame_like_lab042(symbols, samples_per_symbol)
|
||||
waveform *= np.exp(2j * np.pi * CARRIER * np.arange(len(waveform)) / SAMPLE_RATE)
|
||||
waveform *= 44 / np.sqrt(np.mean(abs(waveform)**2))
|
||||
values = np.empty(2 * len(waveform), dtype=np.int8)
|
||||
values[::2] = np.clip(np.rint(waveform.real), -128, 127).astype(np.int8)
|
||||
values[1::2] = np.clip(np.rint(waveform.imag), -128, 127).astype(np.int8)
|
||||
return values
|
||||
|
||||
|
||||
def prepare(directory: Path = RAW_DIRECTORY) -> dict:
|
||||
"""Создать единственный конечный TX-файл; аппаратная частота не назначена."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
tx_path, metadata_path = directory / "tx_abab.cs8", directory / "tx_metadata.json"
|
||||
if tx_path.exists() or metadata_path.exists():
|
||||
raise FileExistsError("Артефакты Lab056 уже существуют")
|
||||
bursts = {rate: build_burst(rate) for rate in (20000, 500000)}
|
||||
segments = []
|
||||
cursor = GAP_SAMPLES
|
||||
with tx_path.open("xb") as stream:
|
||||
stream.write(bytes(2 * GAP_SAMPLES))
|
||||
for label, rate in (("A1", 20000), ("B1", 500000), ("A2", 20000), ("B2", 500000)):
|
||||
values = bursts[rate]
|
||||
count = len(values) // 2
|
||||
complex_values = values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32)
|
||||
whole, active = energy.Moments(), energy.Moments()
|
||||
whole.add(complex_values)
|
||||
trim = 21 * (SAMPLE_RATE // rate)
|
||||
active.add(complex_values[trim:-trim])
|
||||
segments.append({"label": label, "symbol_rate": rate, "start": cursor,
|
||||
"samples": count, "active_start": cursor + trim, "active_samples": count - 2 * trim,
|
||||
"whole": whole.result(rate), "active": active.result(rate)})
|
||||
stream.write(values.tobytes())
|
||||
stream.write(bytes(2 * GAP_SAMPLES))
|
||||
cursor += count + GAP_SAMPLES
|
||||
result = {"stage": "PREPARATION_ONLY", "rf_tx": 0, "rf_frequency_hz": None,
|
||||
"physical_path": "OTA_REQUESTED_FREQUENCY_AND_GEOMETRY_PENDING", "sample_rate_hz": SAMPLE_RATE,
|
||||
"carrier_hz": CARRIER, "tx_samples": cursor, "tx_bytes": 2 * cursor,
|
||||
"tx_seconds": cursor / SAMPLE_RATE, "rx_samples": RX_SAMPLES,
|
||||
"rx_seconds": RX_SAMPLES / SAMPLE_RATE, "rx_lead_seconds": RX_LEAD_SECONDS,
|
||||
"gap_seconds": GAP_SAMPLES / SAMPLE_RATE, "gap_is_zero_digital_iq_not_rf_off": True,
|
||||
"tx_sha256": energy.sha256(tx_path), "segments": segments,
|
||||
"whole_power_difference_db": float(10 * np.log10(segments[1]["whole"]["power_code2"] / segments[0]["whole"]["power_code2"])),
|
||||
"active_power_difference_db": float(10 * np.log10(segments[1]["active"]["power_code2"] / segments[0]["active"]["power_code2"]))}
|
||||
if abs(result["whole_power_difference_db"]) > .01 or any(s["whole"]["clipped_samples_fraction"] for s in segments):
|
||||
raise RuntimeError("Цифровая подготовка не прошла проверку мощности/клиппинга")
|
||||
save_new(metadata_path, result)
|
||||
return result
|
||||
|
||||
|
||||
def locate_sequence(tx: energy.Cs8File, rx: energy.Cs8File) -> dict:
|
||||
"""Диагностическое согласование полной ABAB; TX reference разрешён.
|
||||
|
||||
Грубая сетка 32 отсчёта использует всю последовательность, затем начало
|
||||
каждого burst уточняется прежним методом Lab055 на обучающей плитке.
|
||||
Прореживание без антиалиасинга здесь только для корреляционного поиска,
|
||||
а не для измерений мощности или спектра.
|
||||
"""
|
||||
stride = 32
|
||||
def decimated(source):
|
||||
chunks = [source.read(start, min(262144, source.size - start))[::stride]
|
||||
for start in range(0, source.size, 262144)]
|
||||
return np.concatenate(chunks).astype(np.complex128)
|
||||
reference, received = decimated(tx), decimated(rx)
|
||||
if len(received) < len(reference):
|
||||
raise ValueError("RX короче TX")
|
||||
reference -= reference.mean()
|
||||
received -= received.mean()
|
||||
cumulative = np.concatenate(([0.], np.cumsum(abs(received)**2)))
|
||||
window_energy = cumulative[len(reference):] - cumulative[:-len(reference)]
|
||||
reference_energy = np.sum(abs(reference)**2)
|
||||
time = np.arange(len(reference)) * stride / SAMPLE_RATE
|
||||
best = (-1., 0, 0.)
|
||||
for frequency in np.arange(-2., 2.01, .25):
|
||||
rotated = reference * np.exp(2j * np.pi * frequency * time)
|
||||
correlation = signal.fftconvolve(received, np.conj(rotated[::-1]), mode="valid")
|
||||
scores = 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))
|
||||
# Это качество диагностического совпадения, не критерий PHY/CRC.
|
||||
if best[0] < .05:
|
||||
raise ValueError("ABAB не локализована достаточно уверенно; измерение UNKNOWN")
|
||||
return {"start": best[1], "correlation": best[0], "coarse_cfo_hz": best[2], "stride": stride}
|
||||
|
||||
|
||||
class OffsetSource:
|
||||
def __init__(self, source: energy.Cs8File, offset: int):
|
||||
self.source, self.offset = source, offset
|
||||
|
||||
def read(self, start: int, count: int) -> np.ndarray:
|
||||
return self.source.read(self.offset + start, count)
|
||||
|
||||
|
||||
def window_power(values: np.ndarray, symbol_rate: int) -> dict:
|
||||
moments = energy.Moments()
|
||||
moments.add(values)
|
||||
result = moments.result(symbol_rate)
|
||||
centered = values - values.mean()
|
||||
frequencies, density = energy.spectrum(centered)
|
||||
for width in (27000, 675000):
|
||||
result[f"band_{width}_power"] = energy.band_power(frequencies, density, CARRIER - width / 2, CARRIER + width / 2)
|
||||
size = 32000 # Неперекрывающиеся блоки по 4 мс для оценки разброса мощности.
|
||||
powers = [float(np.mean(abs(centered[start:start + size])**2)) for start in range(0, len(centered) - size + 1, size)]
|
||||
result["power_standard_error"] = float(np.std(powers, ddof=1) / np.sqrt(len(powers))) if len(powers) > 1 else None
|
||||
return result
|
||||
|
||||
|
||||
def difference_db(new: float | None, old: float | None) -> float | None:
|
||||
if new is None or old is None or new <= 0 or old <= 0:
|
||||
return None
|
||||
return float(10 * np.log10(new / old))
|
||||
|
||||
|
||||
def complex_gain(reference: np.ndarray, received: np.ndarray, cfo_hz: float) -> dict:
|
||||
"""Вывести комплексный h прежней модели; те же чётные обучающие плитки."""
|
||||
boundaries = np.linspace(0, len(reference), 9, dtype=int)
|
||||
train = np.zeros(len(reference), dtype=bool)
|
||||
for tile in range(0, 8, 2):
|
||||
train[boundaries[tile]:boundaries[tile + 1]] = True
|
||||
rotated = reference * np.exp(2j * np.pi * cfo_hz * np.arange(len(reference)) / SAMPLE_RATE)
|
||||
x, y = rotated[train], received[train].astype(np.complex128)
|
||||
x, y = x - x.mean(), y - y.mean()
|
||||
gain = np.vdot(x, y) / max(float(np.sum(abs(x)**2)), 1e-30)
|
||||
return {"real": float(gain.real), "imag": float(gain.imag),
|
||||
"phase_rad_at_active_start": float(np.angle(gain))}
|
||||
|
||||
|
||||
def comparison_summary(rows: list[dict]) -> dict:
|
||||
"""Сравнить повторы отдельно; X/Z используют средние линейные мощности."""
|
||||
by_label = {row["label"]: row for row in rows}
|
||||
repeatability = []
|
||||
for mode in ("A", "B"):
|
||||
first, second = by_label[mode + "1"], by_label[mode + "2"]
|
||||
repeatability.append({"comparison": mode + "2/" + mode + "1",
|
||||
"raw_rx_power_difference_db": difference_db(second["raw_rx"]["power_code2"], first["raw_rx"]["power_code2"]),
|
||||
"background_difference_db": difference_db(second["background_power"], first["background_power"]),
|
||||
"received_power_difference_db": difference_db(second["background_subtracted_signal_power"], first["background_subtracted_signal_power"]),
|
||||
"channel_gain_difference_db": difference_db(second["channel_gain_power"], first["channel_gain_power"]),
|
||||
"matched_component_difference_db": difference_db(second["measurement"]["heldout_signal_power"], first["measurement"]["heldout_signal_power"])})
|
||||
def mean(mode, key):
|
||||
values = [by_label[mode + str(number)][key] for number in (1, 2)]
|
||||
return None if any(value is None or value <= 0 for value in values) else float(np.mean(values))
|
||||
a_power, b_power = mean("A", "background_subtracted_signal_power"), mean("B", "background_subtracted_signal_power")
|
||||
return {"repeatability": repeatability,
|
||||
"averaging": "RATIO_OF_MEAN_LINEAR_POWERS_B1_B2_OVER_A1_A2",
|
||||
"received_power_difference_db": difference_db(b_power, a_power),
|
||||
"channel_gain_difference_db": difference_db(mean("B", "channel_gain_power"), mean("A", "channel_gain_power")),
|
||||
"energy_per_symbol_difference_db": difference_db(None if b_power is None else b_power / 500000,
|
||||
None if a_power is None else a_power / 20000),
|
||||
"energy_basis": "BACKGROUND_SUBTRACTED_RECEIVED_POWER_DIVIDED_BY_SYMBOL_RATE"}
|
||||
|
||||
|
||||
def analyze(directory: Path, rx_path: Path) -> dict:
|
||||
metadata = energy.read_json(directory / "tx_metadata.json")
|
||||
tx_path = directory / "tx_abab.cs8"
|
||||
if energy.sha256(tx_path) != metadata["tx_sha256"]:
|
||||
raise ValueError("TX SHA не совпал")
|
||||
tx, rx = energy.Cs8File(tx_path), energy.Cs8File(rx_path)
|
||||
alignment = locate_sequence(tx, rx)
|
||||
rows = []
|
||||
for segment in metadata["segments"]:
|
||||
rate, count = segment["symbol_rate"], segment["samples"]
|
||||
start = alignment["start"] + segment["start"]
|
||||
measurement = energy.analyze_rx(OffsetSource(tx, segment["start"]), rx, [start], count,
|
||||
SAMPLE_RATE // rate)["frames"][0]
|
||||
start = measurement["start"]
|
||||
trim = 21 * (SAMPLE_RATE // rate)
|
||||
received_active = rx.read(start + trim, count - 2 * trim)
|
||||
raw = window_power(received_active, rate)
|
||||
gain = complex_gain(tx.read(segment["start"] + trim, count - 2 * trim), received_active, measurement["cfo_hz"])
|
||||
if not np.isclose(gain["real"]**2 + gain["imag"]**2, measurement["gain_abs"]**2, rtol=1e-10, atol=1e-15):
|
||||
raise ValueError("Вывод комплексного h расходится с прежней моделью Lab055")
|
||||
windows = [window_power(rx.read(begin, 480000), rate) for begin in
|
||||
(start - 640000, start + count + 160000)]
|
||||
background = float(np.mean([w["ac_power_code2"] for w in windows]))
|
||||
excess = raw["ac_power_code2"] - background
|
||||
uncertainty = float(np.sqrt(raw["power_standard_error"]**2 +
|
||||
sum(w["power_standard_error"]**2 for w in windows) / 4 +
|
||||
((windows[0]["ac_power_code2"] - windows[1]["ac_power_code2"]) / 2)**2))
|
||||
stable = excess > 3 * uncertainty
|
||||
rows.append({"label": segment["label"], "symbol_rate": rate, "raw_rx": raw,
|
||||
"local_zero_iq_background_windows": windows, "background_power": background,
|
||||
"background_subtracted_raw": excess, "background_subtracted_uncertainty": uncertainty,
|
||||
"background_subtracted_stable": stable, "background_subtracted_signal_power": excess if stable else None,
|
||||
"channel_gain_complex": gain, "channel_gain_power": measurement["gain_abs"]**2, "measurement": measurement})
|
||||
by_label = {r["label"]: r for r in rows}
|
||||
pairs = []
|
||||
for number in (1, 2):
|
||||
a, b = by_label[f"A{number}"], by_label[f"B{number}"]
|
||||
pairs.append({"pair": number,
|
||||
"received_power_difference_db": difference_db(b["background_subtracted_signal_power"], a["background_subtracted_signal_power"]),
|
||||
"channel_gain_difference_db": difference_db(b["channel_gain_power"], a["channel_gain_power"]),
|
||||
"matched_component_difference_db": difference_db(b["measurement"]["heldout_signal_power"], a["measurement"]["heldout_signal_power"]),
|
||||
"matched_component_symbol_energy_difference_db": difference_db(b["measurement"]["heldout_signal_power"] / b["symbol_rate"], a["measurement"]["heldout_signal_power"] / a["symbol_rate"])})
|
||||
# До начала и после конца всего цифрового файла: отдельные внешние окна.
|
||||
outside = []
|
||||
for begin in (alignment["start"] - 800000, alignment["start"] + tx.size + 400000):
|
||||
if 0 <= begin and begin + 400000 <= rx.size:
|
||||
outside.append({"start": begin, **window_power(rx.read(begin, 400000), 20000)})
|
||||
return {"scope": "TX_REFERENCE_DIAGNOSTICS_NOT_INDEPENDENT_PACKET_RX", "rf_tx": 0,
|
||||
"tx_sha256": metadata["tx_sha256"], "rx_sha256": energy.sha256(rx_path),
|
||||
"alignment": alignment, "segments": rows, "pairs": pairs, "comparison_summary": comparison_summary(rows),
|
||||
"outside_waveform_background": outside,
|
||||
"background_note": "Внутренние gaps: нулевой цифровой IQ при работающем TX; это не RF OFF. Внешние окна также требуют сверки времён запуска/остановки по журналам.",
|
||||
"power_units": "RX code squared, not calibrated watts/dBm",
|
||||
"residual_note": "Шум и неописанные моделью искажения; RX не нормируется для выравнивания A/B"}
|
||||
|
||||
|
||||
def software_capture(directory: Path, b_gain: float, name: str) -> dict:
|
||||
"""Синтетический CS8 с известным усилением, DC и CFO; не аппаратная запись."""
|
||||
metadata = energy.read_json(directory / "tx_metadata.json")
|
||||
tx = energy.Cs8File(directory / "tx_abab.cs8")
|
||||
path = directory / f"synthetic_{name}.cs8"
|
||||
random_generator = np.random.default_rng(56056)
|
||||
lead = 4_160_013
|
||||
with path.open("xb") as stream:
|
||||
for low in range(0, RX_SAMPLES, 262144):
|
||||
count = min(262144, RX_SAMPLES - low)
|
||||
values = (1 + 2j + .6 * (random_generator.normal(size=count) + 1j * random_generator.normal(size=count)))
|
||||
first, last = max(low, lead), min(low + count, lead + tx.size)
|
||||
if first < last:
|
||||
source = tx.read(first - lead, last - first)
|
||||
gains = np.full(len(source), .03)
|
||||
indexes = np.arange(first - lead, last - lead)
|
||||
for segment in metadata["segments"]:
|
||||
if segment["label"].startswith("B"):
|
||||
gains[(indexes >= segment["start"]) & (indexes < segment["start"] + segment["samples"])] = b_gain
|
||||
values[first - low:last - low] += source * gains * np.exp(1j * (.7 + 2 * np.pi * .2 * np.arange(first, last) / SAMPLE_RATE))
|
||||
packed = np.empty(2 * count, dtype=np.int8)
|
||||
packed[::2] = np.clip(np.rint(values.real), -128, 127)
|
||||
packed[1::2] = np.clip(np.rint(values.imag), -128, 127)
|
||||
stream.write(packed.tobytes())
|
||||
result = analyze(directory, path)
|
||||
expected = 20 * np.log10(b_gain / .03)
|
||||
errors = [abs(p["channel_gain_difference_db"] - expected) for p in result["pairs"]]
|
||||
result.update(synthetic=True, expected_gain_difference_db=float(expected),
|
||||
known_sequence_start=lead, passed=bool(max(errors) < .3))
|
||||
if not result["passed"]:
|
||||
raise RuntimeError(f"Синтетическая проверка не прошла: {errors}")
|
||||
return result
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
assert difference_db(.25, 1) == 10 * np.log10(.25)
|
||||
assert difference_db(None, 1) is None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("stage", choices=("prepare", "software", "analyze"))
|
||||
parser.add_argument("--rx", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.stage == "prepare":
|
||||
result = prepare()
|
||||
save_new(OUTPUT / "preparation.json", result)
|
||||
elif args.stage == "software":
|
||||
for name, gain in (("equal", .03), ("minus6db", .015)):
|
||||
result = software_capture(RAW_DIRECTORY, gain, name)
|
||||
save_new(OUTPUT / f"software_{name}.json", result)
|
||||
print(name, result["pairs"], flush=True)
|
||||
return
|
||||
else:
|
||||
if args.rx is None or args.output is None:
|
||||
parser.error("Для сохранённого RX нужны --rx и новый --output")
|
||||
result = analyze(RAW_DIRECTORY, args.rx)
|
||||
save_new(args.output, result)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
263
experiments/lab056_capture_runner.py
Normal file
263
experiments/lab056_capture_runner.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Lab056: конечный RX-first runner; этот модуль не имеет аппаратного CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
from experiments import lab054_hardware_control as previous
|
||||
from experiments import lab056_abab_energy as waveform
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingPoint:
|
||||
"""Частота пользователя и описание неподвижного эфирного тракта."""
|
||||
|
||||
frequency_hz: int | None = None
|
||||
physical_path: str = "MASTER TX -> TX antenna -> free space -> RX antenna -> SLAVE RX"
|
||||
path_confirmed: bool = False
|
||||
tx_antenna: str = "UNKNOWN"
|
||||
rx_antenna: str = "UNKNOWN"
|
||||
distance_m: float | None = None
|
||||
orientation: str = "UNKNOWN"
|
||||
visibility: str = "UNKNOWN"
|
||||
|
||||
def validate(self) -> None:
|
||||
# Допустимую лабораторную частоту определяет пользователь, не этот модуль.
|
||||
if type(self.frequency_hz) is not int or self.frequency_hz <= 0:
|
||||
raise PermissionError("Нужно конкретное целое RF_FREQ в Гц от пользователя")
|
||||
|
||||
|
||||
def command(point: OperatingPoint, path: Path | str, samples: int, *, transmit: bool = False) -> list[str]:
|
||||
point.validate()
|
||||
if not isinstance(samples, int) or samples <= 0:
|
||||
raise ValueError("Нужен положительный конечный счётчик отсчётов")
|
||||
clock = previous.clock
|
||||
result = [str(clock.HACKRF_TOOLS_DIR / "hackrf_transfer.exe"), "-d",
|
||||
clock.MASTER_SERIAL if transmit else clock.SLAVE_SERIAL, "-t" if transmit else "-r",
|
||||
str(path), "-f", str(point.frequency_hz), "-s", str(waveform.SAMPLE_RATE), "-n", str(samples)]
|
||||
return result + (["-x", "10"] if transmit else ["-l", "24", "-g", "24"]) + ["-a", "0", "-p", "0", "-B"]
|
||||
|
||||
|
||||
def common_clock(point: OperatingPoint, runner, guard, *, sleep_function=time.sleep) -> dict:
|
||||
"""Прежняя логика регистров/CLKIN; короткий RX использует новую согласованную RF."""
|
||||
point.validate()
|
||||
def short_receive(serial, command_runner):
|
||||
parameters = command(point, "NUL", previous.clock.SHORT_RX_SAMPLE_COUNT)
|
||||
parameters[parameters.index("-d") + 1] = serial
|
||||
result = command_runner(parameters)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Короткий RX-start не выполнен")
|
||||
preflight = previous.bind(previous.clock.common_clock_preflight, _short_rx=short_receive,
|
||||
_disable_clock_outputs=previous.disable_clock_outputs)
|
||||
return preflight(runner, guard, sleep_function=sleep_function)
|
||||
|
||||
|
||||
def validated_metadata(directory: Path) -> dict:
|
||||
metadata = waveform.energy.read_json(directory / "tx_metadata.json")
|
||||
path = directory / "tx_abab.cs8"
|
||||
if (metadata["stage"] != "PREPARATION_ONLY" or metadata["rf_tx"] != 0 or
|
||||
metadata["sample_rate_hz"] != waveform.SAMPLE_RATE or metadata["carrier_hz"] != waveform.CARRIER or
|
||||
metadata["tx_samples"] * 2 != path.stat().st_size or
|
||||
waveform.energy.sha256(path) != metadata["tx_sha256"] or
|
||||
metadata["rx_samples"] != waveform.RX_SAMPLES or
|
||||
[(s["label"], s["symbol_rate"]) for s in metadata["segments"]] !=
|
||||
[("A1", 20000), ("B1", 500000), ("A2", 20000), ("B2", 500000)] or
|
||||
abs(metadata["whole_power_difference_db"]) > .01):
|
||||
raise ValueError("Подготовленный TX/параметры изменились")
|
||||
return metadata
|
||||
|
||||
|
||||
def clean_counter_log(log: str, expected: str) -> bool:
|
||||
"""Все упоминания потерь должны быть разобраны и иметь явный нулевой счётчик."""
|
||||
pattern = re.compile(r"(?<![\w.+-])(\d+)\s+(?:(?:rx|tx)\s+)?(overruns?|underruns?)\b", re.I)
|
||||
matches = list(pattern.finditer(log))
|
||||
if not matches or any(int(match[1]) != 0 for match in matches):
|
||||
return False
|
||||
if not any(match[2].lower().rstrip("s") == expected for match in matches):
|
||||
return False
|
||||
return re.search(r"overrun|underrun", pattern.sub("", log), re.I) is None
|
||||
|
||||
|
||||
def require_clock_pass(result: dict) -> None:
|
||||
if (not result.get("passed") or result.get("master_reg29") != 0x26 or
|
||||
result.get("slave_reg29") != 0x26 or result.get("slave_clkin_status") != "DETECTED"):
|
||||
raise RuntimeError("Common clock FAIL")
|
||||
|
||||
|
||||
def receive_level(path: Path, *, tail_samples: int | None = None) -> dict:
|
||||
"""Уровень CS8 без изменения gain; rail-коды являются признаком клиппинга."""
|
||||
source = waveform.energy.Cs8File(path)
|
||||
if source.size < 65536:
|
||||
raise RuntimeError("Недостаточно сохранённых RX-отсчётов для проверки уровня")
|
||||
start = max(0, source.size - tail_samples) if tail_samples else 0
|
||||
moments = waveform.energy.Moments()
|
||||
for begin in range(start, source.size, 262144):
|
||||
moments.add(source.read(begin, min(262144, source.size - begin)))
|
||||
result = moments.result(20000)
|
||||
result["saturation_detected"] = result["clipped_samples_fraction"] > 0
|
||||
result["analog_compression"] = "UNKNOWN_WITHOUT_CALIBRATION"
|
||||
return result
|
||||
|
||||
|
||||
def preflight_only(directory: Path, point: OperatingPoint, *, runner=None,
|
||||
process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict:
|
||||
"""Только RX: опора и 0,1 с фона; сохранить результат и STOP до разрешения TX."""
|
||||
point.validate()
|
||||
metadata = validated_metadata(directory)
|
||||
waveform.save_new(directory / "preflight_attempt.json", {"point": asdict(point), "rf_tx_count": 0})
|
||||
actual_runner = runner or previous.clock._default_command_runner
|
||||
process_checker = process_checker or previous.check_processes
|
||||
clock_checker = clock_checker or common_clock
|
||||
def receive_only_runner(parameters):
|
||||
if "-t" in parameters:
|
||||
raise PermissionError("TX запрещён внутри preflight_only")
|
||||
return actual_runner(parameters)
|
||||
record = {"passed": False, "rf_tx_count": 0, "point": asdict(point),
|
||||
"tx_sha256": metadata["tx_sha256"], "stop_before_tx": True}
|
||||
clock_started = False
|
||||
try:
|
||||
before = process_checker()
|
||||
record["process_before"] = before.to_dict()
|
||||
previous.require_clear(before)
|
||||
record["inventory"] = previous.phy.lab045._verify_device_inventory(receive_only_runner)
|
||||
clock_started = True
|
||||
record["clock"] = clock_checker(point, receive_only_runner, previous.clock.TransmitGuard(),
|
||||
sleep_function=sleep_function)
|
||||
require_clock_pass(record["clock"])
|
||||
path = directory / "preflight_rx.cs8"
|
||||
if path.exists():
|
||||
raise FileExistsError("RX preflight уже существует")
|
||||
captured = receive_only_runner(command(point, path, 800000))
|
||||
record["rx_process"] = {"returncode": captured.returncode,
|
||||
"stdout": captured.stdout, "stderr": captured.stderr}
|
||||
if captured.returncode or not clean_counter_log(captured.stdout + captured.stderr, "overrun"):
|
||||
raise RuntimeError("RX preflight: ошибка процесса или счётчиков")
|
||||
if path.stat().st_size != 1600000:
|
||||
raise RuntimeError("Неверный размер RX preflight")
|
||||
record["rx_level"] = receive_level(path)
|
||||
record["rx_sha256"] = waveform.energy.sha256(path)
|
||||
if record["rx_level"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation до TX; STOP без изменения gains")
|
||||
record["passed"] = True
|
||||
except Exception as error:
|
||||
record["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
errors = previous.disable_clock_outputs(receive_only_runner) if clock_started else []
|
||||
try:
|
||||
after = process_checker()
|
||||
record["process_after"] = after.to_dict()
|
||||
previous.require_clear(after)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
record["cleanup_errors"] = errors
|
||||
record["passed"] = record["passed"] and not errors
|
||||
waveform.save_new(directory / "preflight_result.json", record)
|
||||
if not record["passed"]:
|
||||
raise RuntimeError("Preflight cleanup FAIL")
|
||||
return record
|
||||
|
||||
|
||||
def require_preflight(directory: Path, point: OperatingPoint, metadata: dict) -> None:
|
||||
record = waveform.energy.read_json(directory / "preflight_result.json")
|
||||
if (record.get("passed") is not True or record.get("rf_tx_count") != 0 or
|
||||
record.get("point") != asdict(point) or record.get("tx_sha256") != metadata["tx_sha256"]):
|
||||
raise PermissionError("Нужен успешный RX-only preflight именно этой конфигурации")
|
||||
require_clock_pass(record.get("clock", {}))
|
||||
if record.get("rx_level", {}).get("saturation_detected") is not False:
|
||||
raise PermissionError("Нет подтверждённой проверки уровня RX до TX")
|
||||
|
||||
|
||||
def capture_once(directory: Path, point: OperatingPoint, *, authorization: bool = False,
|
||||
runner=None, process_starter=None, process_checker=None,
|
||||
clock_checker=None, sleep_function=time.sleep) -> dict:
|
||||
"""Один RX и один TX; ошибка расходует попытку, автоматического повтора нет."""
|
||||
if authorization is not True:
|
||||
raise PermissionError("Нужно отдельное разрешение на конкретный RF-сеанс Lab056")
|
||||
point.validate()
|
||||
metadata = validated_metadata(directory)
|
||||
require_preflight(directory, point, metadata)
|
||||
receive_path = directory / "rx_stream.cs8"
|
||||
if receive_path.exists() or (directory / "capture_result.json").exists():
|
||||
raise FileExistsError("Аппаратные результаты уже существуют")
|
||||
runner = runner or previous.clock._default_command_runner
|
||||
process_starter = process_starter or previous.clock._default_process_starter
|
||||
process_checker = process_checker or previous.check_processes
|
||||
clock_checker = clock_checker or common_clock
|
||||
waveform.save_new(directory / "hardware_attempt.json", {"authorized_once": True,
|
||||
"point": asdict(point), "tx_sha256": metadata["tx_sha256"], "automatic_retry": False})
|
||||
result = {"passed": False, "rf_tx_count": 0, "point": asdict(point)}
|
||||
guard = previous.clock.TransmitGuard()
|
||||
process, clock_started = None, False
|
||||
try:
|
||||
before = process_checker()
|
||||
result["process_before"] = before.to_dict()
|
||||
previous.require_clear(before)
|
||||
result["inventory"] = previous.phy.lab045._verify_device_inventory(runner)
|
||||
clock_started = True
|
||||
result["clock"] = clock_checker(point, runner, guard, sleep_function=sleep_function)
|
||||
require_clock_pass(result["clock"])
|
||||
process = process_starter(command(point, receive_path, waveform.RX_SAMPLES),
|
||||
directory / "rx_stdout.txt", directory / "rx_stderr.txt")
|
||||
sleep_function(waveform.RX_LEAD_SECONDS)
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("RX завершился до TX")
|
||||
result["rx_level_before_tx"] = receive_level(receive_path, tail_samples=65536)
|
||||
if result["rx_level_before_tx"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation до TX; STOP без изменения gains")
|
||||
guard.register_transmit()
|
||||
result["rf_tx_count"] = guard.transmit_calls
|
||||
transmitted = runner(command(point, directory / "tx_abab.cs8", metadata["tx_samples"], transmit=True))
|
||||
waveform.save_new(directory / "tx_process_result.json", {"returncode": transmitted.returncode,
|
||||
"stdout": transmitted.stdout, "stderr": transmitted.stderr})
|
||||
if transmitted.returncode or process.wait(13):
|
||||
raise RuntimeError("Единственный TX/RX завершился ошибкой")
|
||||
process.close_logs()
|
||||
if receive_path.stat().st_size != waveform.RX_SAMPLES * 2:
|
||||
raise RuntimeError("Неверный размер RX")
|
||||
rx_log = "".join((directory / f"rx_{kind}.txt").read_text(encoding="utf-8") for kind in ("stdout", "stderr"))
|
||||
for role, log, counter in (("RX", rx_log, "overrun"),
|
||||
("TX", transmitted.stdout + transmitted.stderr, "underrun")):
|
||||
if not clean_counter_log(log, counter):
|
||||
raise RuntimeError(f"{role}: нет явного нулевого счётчика потерь")
|
||||
result.update(rx_sha256=waveform.energy.sha256(receive_path), rx_level=receive_level(receive_path))
|
||||
if result["rx_level"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation в сохранённом сеансе; TX уже был, повтор запрещён")
|
||||
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(previous.disable_clock_outputs(runner))
|
||||
try:
|
||||
after = process_checker()
|
||||
result["process_after"] = after.to_dict()
|
||||
previous.require_clear(after)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
result["cleanup_errors"] = errors
|
||||
result["passed"] = result["passed"] and not errors
|
||||
waveform.save_new(directory / "capture_result.json", result)
|
||||
if not result["passed"]:
|
||||
raise RuntimeError("Cleanup FAIL; повтор запрещён")
|
||||
return result
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
try:
|
||||
capture_once(Path("UNUSED"), OperatingPoint())
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Разрешение проверяется раньше любых действий")
|
||||
Reference in New Issue
Block a user