958 lines
39 KiB
Python
958 lines
39 KiB
Python
"""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()
|