Files
SDR-Rover/experiments/lab064_rf_level_linearity.py
2026-09-15 12:03:48 +03:00

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