Lab064: measure OTA level linearity at TX VGA 10 dB

This commit is contained in:
LittleSam129
2026-09-15 12:03:48 +03:00
parent 4437917465
commit cff226e0bf
12 changed files with 2831 additions and 0 deletions

View File

@@ -0,0 +1,957 @@
"""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()