Files
radar_system/python_app/scripts/per_point_switching_check.py
Ayzen 62c0266a6b per-point switching: hardware bring-up, profiles, protocol doc
Verified on the bench that the modified master bitstream excites in every stage
and the RF switch routes: 16 traces per sweep, 62 dB between the two switch
positions of one master port, 9.9k measurements/s. Adds the built master image,
two run profiles, a verification script and librevna_protocol.md, and drops the
stale prototype driver copy that the firmware tree still carried.
2026-09-15 14:41:54 +03:00

195 lines
11 KiB
Python

"""Проверка на железе, что переключение стимула внутри частотной точки работает.
Зачем отдельная проверка. Число триггерных фронтов на частотную точку НЕ
различает стоковый битстрим от доработанного: поле ``stages`` в ``SweepSettings``
стоковое, и сток тоже отрабатывает четыре стадии на точку. Отличие в том, КАКИЕ
стадии излучают. Сток сравнивает счётчик стадий с порт-стадией точно, поэтому при
``port1Stage = 0`` и ``port2Stage = 2`` излучают только стадии 0 и 2, а 1 и 3
остаются без стимула. Доработанный битстрим сравнивает по маске, и порт излучает
во всём блоке: 0 и 1 — порт 1, 2 и 3 — порт 2.
Отсюда две проверки по данным:
1. **Во всех четырёх выходах есть сигнал.** Если выходы 1 и 3 на уровне шума —
в приборе стоковый битстрим, заливка FPGA не удалась.
2. **Выходы попарно совпадают, когда ВЧ-ключ не подключён.** Тогда обе позиции
ключа — это физически один и тот же путь, поэтому 0 ≈ 1 и 2 ≈ 3. Расхождение
при отключённом ключе означает, что по стадиям разъезжается что-то ещё.
Заодно проверяется и контроллер в петле триггера: при ``syncMode = 3`` мастер
семплирует по вернувшемуся триггеру, так что без исправного зеркалирования в STM32
развёртка просто не пойдёт и данных не будет вовсе.
Запуск (на машине, к которой подключены приборы):
.venv/bin/python -m python_app.scripts.per_point_switching_check \\
--config run_config.json --collections 3
"""
from __future__ import annotations
import argparse
import logging
import statistics
from pathlib import Path
import numpy as np
from python_app.hardware_full.librevna_multi_device_driver.models import (
build_output_channels,
normalize_master_stimulus_ports,
)
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger(__name__)
# Порты мастера, которые подставляет контроллер по умолчанию в
# `configure_continuous_sweep`. Держится здесь, чтобы раскладка выходов в этой
# проверке считалась ровно так же, как в драйвере.
MASTER_STIMULUS_PORTS = (1, 2)
# Ниже этого уровня считаем, что стимула в стадии не было. Порог грубый нарочно:
# разница между «излучает» и «не излучает» — это десятки дБ, а не единицы.
NOISE_FLOOR_DBFS = -80.0
# Насколько выходы одной пары могут расходиться при ОТКЛЮЧЁННОМ ключе.
PAIR_TOLERANCE_DB = 6.0
def magnitude_db(values: np.ndarray) -> float:
"""Медианный модуль трассы в дБ; медиана устойчива к отдельным выбросам."""
finite = values[np.isfinite(values)]
if finite.size == 0:
return float("-inf")
magnitude = float(np.median(np.abs(finite)))
if magnitude <= 0.0:
return float("-inf")
return 20.0 * float(np.log10(magnitude))
def build_switch_position_pairs(config: RunConfigModel) -> list[tuple[int, int]]:
"""Пары выходов «один порт мастера в двух положениях ключа».
Берётся из той же модели, что и раскладка стадий, поэтому не разъедется с
драйвером. Соседние индексы сравнивать НЕЛЬЗЯ: при двух портах и двух
положениях выход 0 — порт 1 в A, выход 1 — порт 2 в A, выход 2 — порт 1 в B,
выход 3 — порт 2 в B. Сравнивать надо (0, 2) и (1, 3).
"""
# Те же порты мастера, что подставляет контроллер по умолчанию
# (`configure_continuous_sweep(master_stimulus_ports=(1, 2))`).
channels = build_output_channels(
normalize_master_stimulus_ports(MASTER_STIMULUS_PORTS),
config.matrix_stimulus_switch_positions,
)
by_port: dict[int, list[int]] = {}
for channel in channels:
by_port.setdefault(channel.stimulus_port, []).append(channel.index)
pairs: list[tuple[int, int]] = []
for indices in by_port.values():
ordered = sorted(indices)
pairs.extend(zip(ordered, ordered[1:]))
return pairs
def summarize(collection: SweepCollection) -> dict[int, list[float]]:
"""Сгруппировать медианные уровни s21 по номеру выхода."""
by_output: dict[int, list[float]] = {}
for trace in collection.traces:
by_output.setdefault(trace.combo.output, []).append(magnitude_db(trace.s21))
return by_output
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--config", required=True, type=Path, help="Путь к run_config.json")
parser.add_argument("--collections", type=int, default=3, help="Сколько коллекций снять (по умолчанию 3)")
parser.add_argument(
"--switch-connected",
action="store_true",
help="ВЧ-ключ подключён: выходы одной пары теперь ДОЛЖНЫ различаться "
"(это разные антенны), поэтому расхождение — признак работы, а не ошибки",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
if not config.is_matrix_radar:
raise SystemExit("нужен матричный radar.model (librevna_multi)")
substages = config.matrix_stimulus_switch_positions
outputs = config.output_switch.positions
print(f"конфиг: stimulus_switch_positions={substages}, выходов={outputs}, "
f"входов={config.input_switch.positions}, combos={len(config.combos)}, "
f"driver_mode={config.radar.driver_mode}")
if substages <= 1:
raise SystemExit("stimulus_switch_positions = 1: проверять нечего, режим выключен")
radar = create_matrix_radar_service(config)
radar.open()
try:
radar.configure(config.radar.sweep)
per_output: dict[int, list[float]] = {}
for index in range(max(1, args.collections)):
collection = radar.acquire_collection(collection_id=index + 1)
print(f" коллекция {index + 1}: трасс {len(collection.traces)}")
for output, levels in summarize(collection).items():
per_output.setdefault(output, []).extend(levels)
finally:
radar.close()
if not per_output:
raise SystemExit("данных нет — развёртка не прошла (проверьте петлю триггера и контроллер)")
print("\nмедианный уровень s21 по выходам:")
medians: dict[int, float] = {}
for output in sorted(per_output):
medians[output] = statistics.median(per_output[output])
print(f" выход {output}: {medians[output]:+7.1f} дБ (трасс {len(per_output[output])})")
verdict_ok = True
silent = [o for o, db in medians.items() if db < NOISE_FLOOR_DBFS]
print("\n1) стимул во всех стадиях:")
if silent:
verdict_ok = False
print(f" ПРОВАЛ: без сигнала выходы {silent}. Похоже, в мастере СТОКОВЫЙ битстрим:")
print(" он излучает только в стадиях, точно равных порт-стадии (0 и 2).")
else:
print(" ок: сигнал есть во всех выходах, значит порт излучает во всём блоке стадий")
if args.switch_connected:
print("2) различие выходов внутри пары (ключ ПОДКЛЮЧЁН, ждём разные антенны):")
else:
print("2) попарное совпадение выходов (ключ не подключён, ждём один и тот же путь):")
# Пары берутся из самой модели, а не из арифметики по индексам: сравнивать
# надо ОДИН порт мастера в двух положениях ключа, а соседние индексы — это
# РАЗНЫЕ порты в одном положении (выход 0 = порт 1 A, выход 1 = порт 2 A,
# выход 2 = порт 1 B, выход 3 = порт 2 B).
pairs = build_switch_position_pairs(config)
for low, high in pairs:
if low not in medians or high not in medians:
continue
delta = abs(medians[low] - medians[high])
differs = delta > PAIR_TOLERANCE_DB
# Ключ подключён -> позиции ведут на РАЗНЫЕ антенны, совпадение значило бы,
# что ключ не движется. Ключ не подключён -> путь один, ждём совпадение.
good = differs if args.switch_connected else not differs
if not good:
verdict_ok = False
if args.switch_connected:
status = "ок, ключ переключается" if good else "СОВПАДАЮТ — ключ, похоже, не движется"
else:
status = "ок" if good else "РАСХОЖДЕНИЕ"
print(f" выходы {low} и {high}: расхождение {delta:4.1f} дБ — {status}")
print("\nИТОГ:", "переключение стимула внутри точки работает" if verdict_ok
else "проверка НЕ пройдена, см. выше")
return 0 if verdict_ok else 1
if __name__ == "__main__":
raise SystemExit(main())