Lab046C: recover short 1400 MHz capture offline
This commit is contained in:
@@ -959,3 +959,68 @@ notices. Финальный read-only verifier независимо сверил
|
|||||||
менять задним числом и второй улучшенный pass автоматически не запускать.
|
менять задним числом и второй улучшенный pass автоматически не запускать.
|
||||||
Повторный RF TX, gain ladder, JPEG, video, QPSK, erasure FEC и ARQ без новой
|
Повторный RF TX, gain ladder, JPEG, video, QPSK, erasure FEC и ARQ без новой
|
||||||
отдельной постановки запрещены.
|
отдельной постановки запрещены.
|
||||||
|
|
||||||
|
# Запись 031
|
||||||
|
|
||||||
|
## Дата
|
||||||
|
|
||||||
|
2 сентября 2026 года
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
Lab046C: независимая повторная обработка короткой записи 1400 МГц с отдельным
|
||||||
|
статистическим допуском кандидатов, без нового RF.
|
||||||
|
|
||||||
|
## Выполнено
|
||||||
|
|
||||||
|
Использована сохранённая запись
|
||||||
|
`20260901_172749_1400MHz/rx_1400mhz.cs8`: 19 200 000 комплексных отсчётов cs8,
|
||||||
|
2,4 с при 8 МГц, SHA-256
|
||||||
|
`06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`.
|
||||||
|
HackRF не запускался, `RF TX = 0`. Исторический порог
|
||||||
|
`IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY не изменялись. Исходный
|
||||||
|
аппаратный `CONTROL RESULT = C` сохранён без переписывания.
|
||||||
|
|
||||||
|
До полного независимого декодирования зафиксировано отдельное правило первого
|
||||||
|
каскада только по фону до передачи `0,00 ... 0,50 с`:
|
||||||
|
`pre-TX local-peak Q99.9 + 4 * local-peak MAD`. Q99.9 равен
|
||||||
|
`0.022788675501942635`, MAD равен `0.0027075146790593863`, итоговый порог равен
|
||||||
|
`0.03361873421818018`. На независимом контрольном фоне после передачи
|
||||||
|
`1,60 ... 2,40 с` максимум равен `0.02711654081940651`, кандидатов 0. Порог
|
||||||
|
после просмотра результата не менялся.
|
||||||
|
|
||||||
|
Перед полным проходом независимая проверка выявила небезопасную при отказе ошибку общей точки
|
||||||
|
приёма: противоречивый результат `accepted=True, CRC_FAIL` мог увеличить
|
||||||
|
`VALID_FRAMES`. Добавлена обязательная повторная проверка `CRC PASS`; сценарий
|
||||||
|
после исправления даёт `VALID_FRAMES = 0`. Независимая повторная проверка
|
||||||
|
признала исправление чистым.
|
||||||
|
|
||||||
|
Выполнен ровно один полный проход всей записи без смещений TX, ожидаемого числа
|
||||||
|
кадров, сигнала TX, пакетов, полезной нагрузки и кодированных битов. Получено
|
||||||
|
29 205 локальных максимумов и 1 279 кандидатов первого каскада. Строгий каскад
|
||||||
|
обработал 28 кандидатов: PHY-заголовок PASS/FAIL `3/25`, FEC attempts/PASS/FAIL
|
||||||
|
`3/3/0`, CRC PASS/FAIL `3/0`, `VALID FRAMES = 3`. Поиск завершился по концу
|
||||||
|
записи и списка кандидатов; ограничения безопасности не сработали.
|
||||||
|
|
||||||
|
Независимый результат записан до открытия опорных данных передатчика. Только
|
||||||
|
после этого три действительных кадра сопоставлены с тремя переданными:
|
||||||
|
пропущено 0, `FALSE CRC PASS = 0`, ошибки положения `0/-1/+2` отсчёта. Ошибки
|
||||||
|
до FEC составили `17 + 19 + 29 = 65` бит, после FEC 0 бит, в полезной нагрузке
|
||||||
|
0 байт и 0 бит. Независимая проверка готовых JSON и трёх пар полезной
|
||||||
|
нагрузки/пакета
|
||||||
|
расхождений не выявила. `1400 MHz OFFLINE REPROCESSING = PASS`,
|
||||||
|
`RF PATH ASSESSMENT = HEALTHY`.
|
||||||
|
|
||||||
|
Итоговый программный шлюз перед полным проходом: `python tools/quick_gate.py`
|
||||||
|
код 0 и 512 проверок; `python -m pytest tests/ -q` = 512 passed;
|
||||||
|
`git diff --check` код 0 с информационными уведомлениями LF/CRLF. Git add,
|
||||||
|
commit и push не выполнялись.
|
||||||
|
|
||||||
|
Подробный отчёт и машинно-читаемые результаты сохранены в
|
||||||
|
`docs/lab046c_1400mhz_offline_reprocessing.md` и
|
||||||
|
`data/processed/lab046c_1400mhz_offline/`.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
STOP. Новый RF, повтор Lab046C, изменение порога, JPEG, QPSK и изменение FEC
|
||||||
|
без отдельной постановки запрещены.
|
||||||
|
|||||||
101
docs/lab046c_1400mhz_offline_reprocessing.md
Normal file
101
docs/lab046c_1400mhz_offline_reprocessing.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Повторная обработка записи Lab046C на 1400 МГц
|
||||||
|
|
||||||
|
## Границы работы
|
||||||
|
|
||||||
|
Использована только сохранённая запись:
|
||||||
|
|
||||||
|
`C:\Users\user\Documents\SDR_Rover_RF_Check\20260901_172749_1400MHz\rx_1400mhz.cs8`
|
||||||
|
|
||||||
|
- размер: 38 400 000 байт;
|
||||||
|
- число комплексных отсчётов cs8: 19 200 000;
|
||||||
|
- частота дискретизации: 8 МГц;
|
||||||
|
- длительность: 2,4 с;
|
||||||
|
- SHA-256: `06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`;
|
||||||
|
- число запусков передатчика в этой работе: 0.
|
||||||
|
|
||||||
|
HackRF не запускался. Порог `IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY
|
||||||
|
не изменялись. Исходный аппаратный `CONTROL RESULT = C` не переписывается.
|
||||||
|
|
||||||
|
## Правило первого каскада
|
||||||
|
|
||||||
|
Правило зафиксировано до полного независимого декодирования. Использован только
|
||||||
|
фон до передачи, интервал `0,00 ... 0,50 с`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
candidate_threshold =
|
||||||
|
pre-TX local-peak Q99.9 + 4 * pre-TX local-peak MAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Получено:
|
||||||
|
|
||||||
|
- локальных максимумов: 5 538;
|
||||||
|
- медиана локальных максимумов: `0.0094285998493433`;
|
||||||
|
- MAD локальных максимумов: `0.0027075146790593863`;
|
||||||
|
- Q99: `0.019724003970623016`;
|
||||||
|
- Q99.9: `0.022788675501942635`;
|
||||||
|
- Q99.99: `0.023935016244649887`;
|
||||||
|
- максимум: `0.024017350748181343`;
|
||||||
|
- зафиксированный порог: `0.03361873421818018`.
|
||||||
|
|
||||||
|
Фон после передачи, интервал `1,60 ... 2,40 с`, использован только как
|
||||||
|
независимая контрольная выборка:
|
||||||
|
|
||||||
|
- локальных максимумов: 9 285;
|
||||||
|
- медиана локальных максимумов: `0.009831136092543602`;
|
||||||
|
- MAD локальных максимумов: `0.002742284908890724`;
|
||||||
|
- Q99.9: `0.023004598915576935`;
|
||||||
|
- Q99.99: `0.026167524978518486`;
|
||||||
|
- максимум: `0.02711654081940651`;
|
||||||
|
- кандидатов при зафиксированном пороге: 0.
|
||||||
|
|
||||||
|
После просмотра результата порог не менялся.
|
||||||
|
|
||||||
|
## Независимый результат
|
||||||
|
|
||||||
|
Выполнен ровно один полный проход по всем 19 200 000 отсчётам. Приёмник не
|
||||||
|
получал смещения переданных кадров, их ожидаемое число, сигнал передатчика,
|
||||||
|
пакеты, полезную нагрузку или кодированные биты.
|
||||||
|
|
||||||
|
- локальных максимумов во всей записи: 29 205;
|
||||||
|
- кандидатов первого каскада: 1 279;
|
||||||
|
- обработано строгим каскадом: 28;
|
||||||
|
- PHY-заголовок прошли: 3;
|
||||||
|
- PHY-заголовок не прошли: 25;
|
||||||
|
- попыток FEC: 3;
|
||||||
|
- FEC прошли: 3;
|
||||||
|
- FEC не прошли: 0;
|
||||||
|
- CRC прошли: 3;
|
||||||
|
- CRC не прошли: 0;
|
||||||
|
- действительных кадров: 3.
|
||||||
|
|
||||||
|
Поиск дошёл до конца записи и до конца списка кандидатов. Ограничения числа
|
||||||
|
кандидатов, итераций и действительных кадров не сработали. Все 25 отклонённых
|
||||||
|
кандидатов получили `PHY_HEADER_MAGIC_MISMATCH`. Только `CRC PASS` добавлял кадр
|
||||||
|
в независимый результат.
|
||||||
|
|
||||||
|
## Диагностика после независимого решения
|
||||||
|
|
||||||
|
Независимый JSON был записан до открытия опорных файлов передатчика. После
|
||||||
|
этого выполнено сопоставление:
|
||||||
|
|
||||||
|
- ожидаемых переданных кадров: 3;
|
||||||
|
- совпавших действительных кадров: 3;
|
||||||
|
- пропущенных кадров: 0;
|
||||||
|
- ложных `CRC PASS`: 0;
|
||||||
|
- ошибки положения начала: `0`, `-1`, `+2` отсчёта;
|
||||||
|
- ошибки кодированных битов до FEC: `17 + 19 + 29 = 65`;
|
||||||
|
- ошибки пакета после FEC: 0 бит;
|
||||||
|
- ошибки полезной нагрузки: 0 байт, 0 бит.
|
||||||
|
|
||||||
|
Три полезные нагрузки по 256 байт формируют соответствующие переданные пакеты
|
||||||
|
по 268 байт. Добавления пропущенных кадров в независимый результат не было.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Повторная обработка записи на 1400 МГц получила `VALID FRAMES = 3` и
|
||||||
|
`CRC PASS = 3` без ложных срабатываний CRC. Это подтверждает исправность
|
||||||
|
физического радиочастотного тракта для данного короткого опыта. Оценка:
|
||||||
|
`RF PATH ASSESSMENT = HEALTHY`.
|
||||||
|
|
||||||
|
Результат не является разрешением на новый RF, повтор Lab046C, JPEG, QPSK или
|
||||||
|
изменение FEC. `RF TX = 0`.
|
||||||
@@ -35,6 +35,13 @@ DEFAULT_CAPTURE_PATH = Path(
|
|||||||
DEFAULT_REFERENCE_DIRECTORY = DEFAULT_CAPTURE_PATH.parent
|
DEFAULT_REFERENCE_DIRECTORY = DEFAULT_CAPTURE_PATH.parent
|
||||||
DEFAULT_OUTPUT_DIRECTORY = Path("data/processed/lab046c_offline_recovery")
|
DEFAULT_OUTPUT_DIRECTORY = Path("data/processed/lab046c_offline_recovery")
|
||||||
|
|
||||||
|
CAPTURE_1400MHZ_PATH = Path(
|
||||||
|
r"C:\Users\user\Documents\SDR_Rover_RF_Check"
|
||||||
|
r"\20260901_172749_1400MHz\rx_1400mhz.cs8"
|
||||||
|
)
|
||||||
|
REFERENCE_1400MHZ_DIRECTORY = CAPTURE_1400MHZ_PATH.parent
|
||||||
|
OUTPUT_1400MHZ_DIRECTORY = Path("data/processed/lab046c_1400mhz_offline")
|
||||||
|
|
||||||
# По плану аппаратного опыта RX стартовал за 0.75 s до TX, а после TX оставалось
|
# По плану аппаратного опыта RX стартовал за 0.75 s до TX, а после TX оставалось
|
||||||
# около 3.65 s. Более узкие интервалы оставляют защитный запас с обеих сторон.
|
# около 3.65 s. Более узкие интервалы оставляют защитный запас с обеих сторон.
|
||||||
CALIBRATION_START_SAMPLE = 0
|
CALIBRATION_START_SAMPLE = 0
|
||||||
@@ -43,6 +50,9 @@ CONTROL_START_SAMPLE = 168_000_000 # 21.0 .. 24.0 s
|
|||||||
CONTROL_END_SAMPLE = 192_000_000
|
CONTROL_END_SAMPLE = 192_000_000
|
||||||
|
|
||||||
BACKGROUND_LOCAL_PEAK_QUANTILE = 0.999
|
BACKGROUND_LOCAL_PEAK_QUANTILE = 0.999
|
||||||
|
BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE = 0.999
|
||||||
|
BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0
|
||||||
|
FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018
|
||||||
DETECTION_CHUNK_SAMPLES = 16_000_000
|
DETECTION_CHUNK_SAMPLES = 16_000_000
|
||||||
MAX_CANDIDATES = 10_000
|
MAX_CANDIDATES = 10_000
|
||||||
MAX_SEARCH_ITERATIONS = 10_000
|
MAX_SEARCH_ITERATIONS = 10_000
|
||||||
@@ -80,6 +90,20 @@ CONTROL_INTERVAL = SampleInterval(
|
|||||||
CONTROL_END_SAMPLE,
|
CONTROL_END_SAMPLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RX-процесс начался раньше TX. Передатчик работал в интервале
|
||||||
|
# 0.602438700 ... 1.489154700 s относительно начала RX. Границы ниже
|
||||||
|
# оставляют более 100 ms защитного запаса с обеих сторон.
|
||||||
|
CALIBRATION_1400MHZ_INTERVAL = SampleInterval(
|
||||||
|
"PRE_TX_BACKGROUND_1400MHZ",
|
||||||
|
0,
|
||||||
|
4_000_000,
|
||||||
|
)
|
||||||
|
CONTROL_1400MHZ_INTERVAL = SampleInterval(
|
||||||
|
"POST_TX_BACKGROUND_1400MHZ",
|
||||||
|
12_800_000,
|
||||||
|
19_200_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CandidateRule:
|
class CandidateRule:
|
||||||
@@ -99,6 +123,8 @@ class BackgroundStatistics:
|
|||||||
candidate_count: int
|
candidate_count: int
|
||||||
candidates_per_second: float
|
candidates_per_second: float
|
||||||
elapsed_seconds: float
|
elapsed_seconds: float
|
||||||
|
local_peak_median: float = float("nan")
|
||||||
|
local_peak_mad: float = float("nan")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -222,6 +248,21 @@ class PostVerdictDiagnostics:
|
|||||||
offline_recovery: str
|
offline_recovery: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ShortCapturePostVerdictDiagnostics:
|
||||||
|
stage: str
|
||||||
|
expected_tx_frames: int
|
||||||
|
matched_valid_frames: int
|
||||||
|
missed_tx_frames: tuple[int, ...]
|
||||||
|
false_crc_pass: int
|
||||||
|
sample_offset_errors: tuple[int, ...]
|
||||||
|
total_pre_fec_coded_bit_errors: int | None
|
||||||
|
total_post_fec_protocol_bit_errors: int | None
|
||||||
|
total_payload_byte_errors: int | None
|
||||||
|
total_payload_bit_errors: int | None
|
||||||
|
rf_path_assessment: str
|
||||||
|
|
||||||
|
|
||||||
ScoreFunction = Callable[[np.ndarray, np.ndarray], np.ndarray]
|
ScoreFunction = Callable[[np.ndarray, np.ndarray], np.ndarray]
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +397,8 @@ def _background_statistics(
|
|||||||
) -> BackgroundStatistics:
|
) -> BackgroundStatistics:
|
||||||
median = float(np.median(scores))
|
median = float(np.median(scores))
|
||||||
mad = float(np.median(np.abs(scores - median)))
|
mad = float(np.median(np.abs(scores - median)))
|
||||||
|
local_peak_median = float(np.median(peaks))
|
||||||
|
local_peak_mad = float(np.median(np.abs(peaks - local_peak_median)))
|
||||||
quantiles = {
|
quantiles = {
|
||||||
"0.99": float(np.quantile(peaks, 0.99)),
|
"0.99": float(np.quantile(peaks, 0.99)),
|
||||||
"0.999": float(np.quantile(peaks, 0.999)),
|
"0.999": float(np.quantile(peaks, 0.999)),
|
||||||
@@ -374,9 +417,30 @@ def _background_statistics(
|
|||||||
candidate_count=candidate_count,
|
candidate_count=candidate_count,
|
||||||
candidates_per_second=candidate_count / interval.duration_seconds,
|
candidates_per_second=candidate_count / interval.duration_seconds,
|
||||||
elapsed_seconds=elapsed_seconds,
|
elapsed_seconds=elapsed_seconds,
|
||||||
|
local_peak_median=local_peak_median,
|
||||||
|
local_peak_mad=local_peak_mad,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_threshold_from_local_peaks(
|
||||||
|
peaks: np.ndarray,
|
||||||
|
*,
|
||||||
|
quantile: float,
|
||||||
|
mad_multiplier: float,
|
||||||
|
) -> float:
|
||||||
|
"""Получить порог как высокий квантиль плюс запас в единицах MAD."""
|
||||||
|
|
||||||
|
values = np.asarray(peaks, dtype=np.float32)
|
||||||
|
if values.ndim != 1 or values.size == 0:
|
||||||
|
raise ValueError("Нужен непустой одномерный массив локальных максимумов")
|
||||||
|
if not 0.0 <= quantile <= 1.0 or mad_multiplier < 0.0:
|
||||||
|
raise ValueError("Параметры статистического порога вне допустимого диапазона")
|
||||||
|
median = float(np.median(values))
|
||||||
|
mad = float(np.median(np.abs(values - median)))
|
||||||
|
quantile_value = float(np.quantile(values, quantile))
|
||||||
|
return quantile_value + mad_multiplier * mad
|
||||||
|
|
||||||
|
|
||||||
def calibrate_candidate_rule(
|
def calibrate_candidate_rule(
|
||||||
capture_path: Path,
|
capture_path: Path,
|
||||||
interval: SampleInterval = CALIBRATION_INTERVAL,
|
interval: SampleInterval = CALIBRATION_INTERVAL,
|
||||||
@@ -393,6 +457,58 @@ def calibrate_candidate_rule(
|
|||||||
return rule, _background_statistics(interval, scores, peaks, threshold, elapsed)
|
return rule, _background_statistics(interval, scores, peaks, threshold, elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_1400mhz_candidate_rule(
|
||||||
|
capture_path: Path,
|
||||||
|
) -> tuple[CandidateRule, BackgroundStatistics]:
|
||||||
|
"""Воспроизвести заранее зафиксированный порог только по фону до TX."""
|
||||||
|
|
||||||
|
scores, peaks, elapsed = _background_peak_scores(
|
||||||
|
capture_path,
|
||||||
|
CALIBRATION_1400MHZ_INTERVAL,
|
||||||
|
)
|
||||||
|
reproduced = candidate_threshold_from_local_peaks(
|
||||||
|
peaks,
|
||||||
|
quantile=BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
|
||||||
|
mad_multiplier=BACKGROUND_1400MHZ_MAD_MULTIPLIER,
|
||||||
|
)
|
||||||
|
if not math.isclose(
|
||||||
|
reproduced,
|
||||||
|
FIXED_1400MHZ_CANDIDATE_THRESHOLD,
|
||||||
|
rel_tol=0.0,
|
||||||
|
abs_tol=1e-12,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Фоновая статистика не воспроизвела заранее зафиксированный "
|
||||||
|
f"порог: {reproduced!r}"
|
||||||
|
)
|
||||||
|
rule = CandidateRule(
|
||||||
|
method="pre-TX local-peak Q99.9 + 4 * local-peak MAD",
|
||||||
|
local_peak_quantile=BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
|
||||||
|
threshold=FIXED_1400MHZ_CANDIDATE_THRESHOLD,
|
||||||
|
)
|
||||||
|
statistics = _background_statistics(
|
||||||
|
CALIBRATION_1400MHZ_INTERVAL,
|
||||||
|
scores,
|
||||||
|
peaks,
|
||||||
|
rule.threshold,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
return rule, statistics
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_1400mhz_control_background(
|
||||||
|
capture_path: Path,
|
||||||
|
rule: CandidateRule,
|
||||||
|
) -> BackgroundStatistics:
|
||||||
|
"""Проверить зафиксированный порог на независимом фоне после TX."""
|
||||||
|
|
||||||
|
return evaluate_control_background(
|
||||||
|
capture_path,
|
||||||
|
rule,
|
||||||
|
interval=CONTROL_1400MHZ_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def evaluate_control_background(
|
def evaluate_control_background(
|
||||||
capture_path: Path,
|
capture_path: Path,
|
||||||
rule: CandidateRule,
|
rule: CandidateRule,
|
||||||
@@ -709,7 +825,11 @@ def strict_process_candidates(
|
|||||||
break
|
break
|
||||||
outcome = decoder(raw, sample_count, candidate, len(outcomes) + 1)
|
outcome = decoder(raw, sample_count, candidate, len(outcomes) + 1)
|
||||||
outcomes.append(outcome)
|
outcomes.append(outcome)
|
||||||
if outcome.accepted and outcome.frame is not None:
|
if (
|
||||||
|
outcome.accepted
|
||||||
|
and outcome.frame is not None
|
||||||
|
and outcome.crc == lab046c.lab045.CRC_PASS
|
||||||
|
):
|
||||||
accepted_frames.append(outcome.frame)
|
accepted_frames.append(outcome.frame)
|
||||||
cursor = max(cursor + 1, int(outcome.next_sample))
|
cursor = max(cursor + 1, int(outcome.next_sample))
|
||||||
cursor_history.append(cursor)
|
cursor_history.append(cursor)
|
||||||
@@ -782,14 +902,18 @@ def assemble_independent_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult:
|
def _run_with_fixed_candidate_rule(
|
||||||
"""Один полный RX-only прогон; API не принимает ни одного TX oracle."""
|
capture_path: Path,
|
||||||
|
rule: CandidateRule,
|
||||||
|
calibration: BackgroundStatistics,
|
||||||
|
control: BackgroundStatistics,
|
||||||
|
*,
|
||||||
|
full_started: float,
|
||||||
|
) -> IndependentRecoveryResult:
|
||||||
|
"""Обработать весь RX после окончательной фиксации правила кандидатов."""
|
||||||
|
|
||||||
full_started = time.perf_counter()
|
|
||||||
capture_path = Path(capture_path)
|
capture_path = Path(capture_path)
|
||||||
_, sample_count = _validate_capture(capture_path)
|
_, sample_count = _validate_capture(capture_path)
|
||||||
rule, calibration = calibrate_candidate_rule(capture_path)
|
|
||||||
control = evaluate_control_background(capture_path, rule)
|
|
||||||
coarse = coarse_search_capture(capture_path, rule)
|
coarse = coarse_search_capture(capture_path, rule)
|
||||||
if coarse.max_candidates_reached:
|
if coarse.max_candidates_reached:
|
||||||
strict = strict_process_candidates(
|
strict = strict_process_candidates(
|
||||||
@@ -827,6 +951,43 @@ def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult:
|
||||||
|
"""Сохранённый 24-секундный RX-only прогон без TX-оракулов."""
|
||||||
|
|
||||||
|
full_started = time.perf_counter()
|
||||||
|
rule, calibration = calibrate_candidate_rule(capture_path)
|
||||||
|
control = evaluate_control_background(capture_path, rule)
|
||||||
|
return _run_with_fixed_candidate_rule(
|
||||||
|
capture_path,
|
||||||
|
rule,
|
||||||
|
calibration,
|
||||||
|
control,
|
||||||
|
full_started=full_started,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_1400mhz_independent_recovery(
|
||||||
|
capture_path: Path,
|
||||||
|
) -> IndependentRecoveryResult:
|
||||||
|
"""Один полный проход записи 1400 МГц без сведений от передатчика."""
|
||||||
|
|
||||||
|
full_started = time.perf_counter()
|
||||||
|
rule, calibration = calibrate_1400mhz_candidate_rule(capture_path)
|
||||||
|
control = evaluate_1400mhz_control_background(capture_path, rule)
|
||||||
|
if control.candidate_count != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Независимый контрольный фон создал кандидаты; "
|
||||||
|
"полный проход остановлен без изменения порога"
|
||||||
|
)
|
||||||
|
return _run_with_fixed_candidate_rule(
|
||||||
|
capture_path,
|
||||||
|
rule,
|
||||||
|
calibration,
|
||||||
|
control,
|
||||||
|
full_started=full_started,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def diagnose_after_independent_verdict(
|
def diagnose_after_independent_verdict(
|
||||||
independent: IndependentRecoveryResult,
|
independent: IndependentRecoveryResult,
|
||||||
reference_directory: Path,
|
reference_directory: Path,
|
||||||
@@ -919,6 +1080,115 @@ def diagnose_after_independent_verdict(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _numbered_reference_files(
|
||||||
|
reference_directory: Path,
|
||||||
|
prefix: str,
|
||||||
|
) -> dict[int, Path]:
|
||||||
|
files: dict[int, Path] = {}
|
||||||
|
for path in Path(reference_directory).glob(f"{prefix}_*.bin"):
|
||||||
|
try:
|
||||||
|
index = int(path.stem.rsplit("_", 1)[1])
|
||||||
|
except (IndexError, ValueError) as error:
|
||||||
|
raise RuntimeError(f"Некорректное имя опорного файла: {path.name}") from error
|
||||||
|
if index in files:
|
||||||
|
raise RuntimeError(f"Повтор индекса опорного файла: {index}")
|
||||||
|
files[index] = path
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_1400mhz_after_independent_verdict(
|
||||||
|
independent: IndependentRecoveryResult,
|
||||||
|
reference_directory: Path,
|
||||||
|
) -> ShortCapturePostVerdictDiagnostics:
|
||||||
|
"""Сравнить CRC PASS кадры с TX только после независимого решения."""
|
||||||
|
|
||||||
|
if not independent.completed:
|
||||||
|
raise RuntimeError("Независимый результат ещё не завершён")
|
||||||
|
reference_directory = Path(reference_directory)
|
||||||
|
payload_paths = _numbered_reference_files(reference_directory, "tx_payload")
|
||||||
|
packet_paths = _numbered_reference_files(reference_directory, "tx_packet")
|
||||||
|
if not payload_paths or payload_paths.keys() != packet_paths.keys():
|
||||||
|
raise RuntimeError("Наборы TX payload и packet отсутствуют или не совпадают")
|
||||||
|
|
||||||
|
plans: dict[int, lab046c.lab045.TransmissionPlan] = {}
|
||||||
|
index_by_packet: dict[bytes, int] = {}
|
||||||
|
for index in sorted(payload_paths):
|
||||||
|
payload = payload_paths[index].read_bytes()
|
||||||
|
packet = packet_paths[index].read_bytes()
|
||||||
|
plan = lab046c.lab045.build_transmission_plan(payload)
|
||||||
|
if plan.packet != packet:
|
||||||
|
raise RuntimeError(f"TX packet {index} не соответствует TX payload")
|
||||||
|
if packet in index_by_packet:
|
||||||
|
raise RuntimeError("Переданные packets не уникальны")
|
||||||
|
plans[index] = plan
|
||||||
|
index_by_packet[packet] = index
|
||||||
|
|
||||||
|
matched: list[tuple[int, lab046b.IqFrameReception]] = []
|
||||||
|
false_crc_pass = 0
|
||||||
|
pre_fec_errors = 0
|
||||||
|
post_fec_errors = 0
|
||||||
|
payload_byte_errors = 0
|
||||||
|
payload_bit_errors = 0
|
||||||
|
for received in independent.strict.accepted_frames:
|
||||||
|
packet = received.reception.packet_bytes
|
||||||
|
index = index_by_packet.get(packet) if packet is not None else None
|
||||||
|
if index is None:
|
||||||
|
false_crc_pass += 1
|
||||||
|
continue
|
||||||
|
matched.append((index, received))
|
||||||
|
comparison = lab046c.lab045.diagnostic_pre_and_post_fec(
|
||||||
|
received.reception,
|
||||||
|
received.post_preamble_soft,
|
||||||
|
plans[index],
|
||||||
|
)
|
||||||
|
pre_fec_errors += int(comparison["pre_fec_coded"]["bit_errors"] or 0)
|
||||||
|
post_fec_errors += int(comparison["post_fec_packet"]["bit_errors"] or 0)
|
||||||
|
payload_byte_errors += int(comparison["post_fec_payload"]["byte_errors"] or 0)
|
||||||
|
payload_bit_errors += int(comparison["post_fec_payload"]["bit_errors"] or 0)
|
||||||
|
|
||||||
|
matched_indexes = {index for index, _ in matched}
|
||||||
|
expected_indexes = set(plans)
|
||||||
|
missed = tuple(sorted(expected_indexes - matched_indexes))
|
||||||
|
sample_errors: tuple[int, ...] = ()
|
||||||
|
if matched:
|
||||||
|
first_plan = plans[min(plans)]
|
||||||
|
frame_samples = first_plan.waveform_sample_count
|
||||||
|
base_estimates = [
|
||||||
|
frame.refined_start_sample - index * frame_samples
|
||||||
|
for index, frame in matched
|
||||||
|
]
|
||||||
|
base = int(round(float(np.median(base_estimates))))
|
||||||
|
sample_errors = tuple(
|
||||||
|
frame.refined_start_sample - (base + index * frame_samples)
|
||||||
|
for index, frame in sorted(
|
||||||
|
matched,
|
||||||
|
key=lambda row: (row[0], row[1].refined_start_sample),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if false_crc_pass:
|
||||||
|
assessment = "INSUFFICIENT"
|
||||||
|
elif len(matched_indexes) == len(expected_indexes) and not missed:
|
||||||
|
assessment = "HEALTHY"
|
||||||
|
elif matched_indexes:
|
||||||
|
assessment = "PARTIAL"
|
||||||
|
else:
|
||||||
|
assessment = "INSUFFICIENT"
|
||||||
|
return ShortCapturePostVerdictDiagnostics(
|
||||||
|
stage="AFTER_INDEPENDENT_VERDICT_ONLY",
|
||||||
|
expected_tx_frames=len(plans),
|
||||||
|
matched_valid_frames=len(matched_indexes),
|
||||||
|
missed_tx_frames=missed,
|
||||||
|
false_crc_pass=false_crc_pass,
|
||||||
|
sample_offset_errors=sample_errors,
|
||||||
|
total_pre_fec_coded_bit_errors=pre_fec_errors if matched else None,
|
||||||
|
total_post_fec_protocol_bit_errors=post_fec_errors if matched else None,
|
||||||
|
total_payload_byte_errors=payload_byte_errors if matched else None,
|
||||||
|
total_payload_bit_errors=payload_bit_errors if matched else None,
|
||||||
|
rf_path_assessment=assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _independent_summary(result: IndependentRecoveryResult) -> dict:
|
def _independent_summary(result: IndependentRecoveryResult) -> dict:
|
||||||
return {
|
return {
|
||||||
"completed": result.completed,
|
"completed": result.completed,
|
||||||
@@ -998,11 +1268,45 @@ def write_results(
|
|||||||
return independent_path, diagnostic_path, recovered_path
|
return independent_path, diagnostic_path, recovered_path
|
||||||
|
|
||||||
|
|
||||||
|
def write_1400mhz_independent_result(
|
||||||
|
output_directory: Path,
|
||||||
|
independent: IndependentRecoveryResult,
|
||||||
|
) -> Path:
|
||||||
|
"""Зафиксировать независимый результат до открытия TX reference."""
|
||||||
|
|
||||||
|
output_directory = Path(output_directory)
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
independent_path = output_directory / "independent_result.json"
|
||||||
|
independent_path.write_text(
|
||||||
|
json.dumps(_independent_summary(independent), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return independent_path
|
||||||
|
|
||||||
|
|
||||||
|
def write_1400mhz_post_verdict_diagnostics(
|
||||||
|
output_directory: Path,
|
||||||
|
diagnostics: ShortCapturePostVerdictDiagnostics,
|
||||||
|
) -> Path:
|
||||||
|
"""Сохранить диагностику, выполненную после независимого результата."""
|
||||||
|
|
||||||
|
output_directory = Path(output_directory)
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
diagnostic_path = output_directory / "post_verdict_diagnostics.json"
|
||||||
|
diagnostic_path.write_text(
|
||||||
|
json.dumps(asdict(diagnostics), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return diagnostic_path
|
||||||
|
|
||||||
|
|
||||||
def run_functional_tests() -> None:
|
def run_functional_tests() -> None:
|
||||||
assert lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
|
assert lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
|
||||||
assert BACKGROUND_LOCAL_PEAK_QUANTILE == 0.999
|
assert BACKGROUND_LOCAL_PEAK_QUANTILE == 0.999
|
||||||
|
assert FIXED_1400MHZ_CANDIDATE_THRESHOLD == 0.03361873421818018
|
||||||
assert MAX_VALID_FRAMES > 68
|
assert MAX_VALID_FRAMES > 68
|
||||||
assert CALIBRATION_INTERVAL.end_sample < CONTROL_INTERVAL.start_sample
|
assert CALIBRATION_INTERVAL.end_sample < CONTROL_INTERVAL.start_sample
|
||||||
|
assert CALIBRATION_1400MHZ_INTERVAL.end_sample < CONTROL_1400MHZ_INTERVAL.start_sample
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -1014,14 +1318,67 @@ def main() -> int:
|
|||||||
default=DEFAULT_REFERENCE_DIRECTORY,
|
default=DEFAULT_REFERENCE_DIRECTORY,
|
||||||
)
|
)
|
||||||
parser.add_argument("--output-directory", type=Path, default=DEFAULT_OUTPUT_DIRECTORY)
|
parser.add_argument("--output-directory", type=Path, default=DEFAULT_OUTPUT_DIRECTORY)
|
||||||
parser.add_argument("--run-original-capture", action="store_true")
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("--run-original-capture", action="store_true")
|
||||||
|
mode.add_argument("--run-1400mhz-capture", action="store_true")
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
if not arguments.run_original_capture:
|
if not arguments.run_original_capture and not arguments.run_1400mhz_capture:
|
||||||
run_functional_tests()
|
run_functional_tests()
|
||||||
print("LAB046C OFFLINE SOFTWARE PREFLIGHT = PASS")
|
print("LAB046C OFFLINE SOFTWARE PREFLIGHT = PASS")
|
||||||
print("RF TX = 0")
|
print("RF TX = 0")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if arguments.run_1400mhz_capture:
|
||||||
|
capture = (
|
||||||
|
arguments.capture
|
||||||
|
if arguments.capture != DEFAULT_CAPTURE_PATH
|
||||||
|
else CAPTURE_1400MHZ_PATH
|
||||||
|
)
|
||||||
|
reference_directory = (
|
||||||
|
arguments.reference_directory
|
||||||
|
if arguments.reference_directory != DEFAULT_REFERENCE_DIRECTORY
|
||||||
|
else REFERENCE_1400MHZ_DIRECTORY
|
||||||
|
)
|
||||||
|
output_directory = (
|
||||||
|
arguments.output_directory
|
||||||
|
if arguments.output_directory != DEFAULT_OUTPUT_DIRECTORY
|
||||||
|
else OUTPUT_1400MHZ_DIRECTORY
|
||||||
|
)
|
||||||
|
independent = run_1400mhz_independent_recovery(capture)
|
||||||
|
if not independent.completed:
|
||||||
|
print(json.dumps({
|
||||||
|
"independent": _independent_summary(independent),
|
||||||
|
"post_verdict": None,
|
||||||
|
"reason": "INDEPENDENT_SCAN_INCOMPLETE_FAIL_CLOSED",
|
||||||
|
"rf_tx": 0,
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
return 2
|
||||||
|
independent_path = write_1400mhz_independent_result(
|
||||||
|
output_directory,
|
||||||
|
independent,
|
||||||
|
)
|
||||||
|
diagnostics = diagnose_1400mhz_after_independent_verdict(
|
||||||
|
independent,
|
||||||
|
reference_directory,
|
||||||
|
)
|
||||||
|
diagnostic_path = write_1400mhz_post_verdict_diagnostics(
|
||||||
|
output_directory,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
print(json.dumps({
|
||||||
|
"completed": independent.completed,
|
||||||
|
"rule": asdict(independent.rule),
|
||||||
|
"calibration": asdict(independent.calibration),
|
||||||
|
"control": asdict(independent.control),
|
||||||
|
"total_local_peaks": independent.coarse.total_local_peaks,
|
||||||
|
"first_stage_candidates": len(independent.coarse.candidates),
|
||||||
|
"strict_counters": asdict(independent.strict.counters),
|
||||||
|
"post_verdict": asdict(diagnostics),
|
||||||
|
"written_paths": [str(independent_path), str(diagnostic_path)],
|
||||||
|
"rf_tx": 0,
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
return 3 if diagnostics.false_crc_pass else 0
|
||||||
|
|
||||||
independent = run_independent_recovery(arguments.capture)
|
independent = run_independent_recovery(arguments.capture)
|
||||||
if not independent.completed:
|
if not independent.completed:
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import inspect
|
import inspect
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct
|
import struct
|
||||||
|
from types import SimpleNamespace
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -128,6 +129,39 @@ def test_calibration_uses_only_fixed_local_peak_quantile(monkeypatch) -> None:
|
|||||||
assert statistics.candidate_count == int(np.count_nonzero(peaks >= rule.threshold))
|
assert statistics.candidate_count == int(np.count_nonzero(peaks >= rule.threshold))
|
||||||
|
|
||||||
|
|
||||||
|
def test_1400mhz_threshold_formula_is_reproducible() -> None:
|
||||||
|
peaks = np.asarray([0.01, 0.02, 0.021, 0.022, 0.024], dtype=np.float32)
|
||||||
|
first = offline.candidate_threshold_from_local_peaks(
|
||||||
|
peaks,
|
||||||
|
quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
|
||||||
|
mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER,
|
||||||
|
)
|
||||||
|
second = offline.candidate_threshold_from_local_peaks(
|
||||||
|
peaks.copy(),
|
||||||
|
quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
|
||||||
|
mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER,
|
||||||
|
)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_1400mhz_control_background_creates_no_candidates(monkeypatch) -> None:
|
||||||
|
scores = np.asarray([0.001, 0.01, 0.02], dtype=np.float32)
|
||||||
|
peaks = np.asarray([0.02, 0.02711654081940651], dtype=np.float32)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
offline,
|
||||||
|
"_background_peak_scores",
|
||||||
|
lambda path, interval: (scores, peaks, 0.1),
|
||||||
|
)
|
||||||
|
rule = offline.CandidateRule(
|
||||||
|
"fixed before independent decoding",
|
||||||
|
0.999,
|
||||||
|
offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD,
|
||||||
|
)
|
||||||
|
statistics = offline.evaluate_1400mhz_control_background(Path("unused"), rule)
|
||||||
|
assert statistics.candidate_count == 0
|
||||||
|
assert max(peaks) < rule.threshold
|
||||||
|
|
||||||
|
|
||||||
def test_control_background_cannot_change_fixed_rule(monkeypatch) -> None:
|
def test_control_background_cannot_change_fixed_rule(monkeypatch) -> None:
|
||||||
peaks = np.asarray([0.01, 0.02, 0.20], dtype=np.float32)
|
peaks = np.asarray([0.01, 0.02, 0.20], dtype=np.float32)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -214,6 +248,67 @@ def test_noise_candidates_never_become_valid_without_crc(
|
|||||||
assert result.counters.crc_pass == 0
|
assert result.counters.crc_pass == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inconsistent_accepted_flag_cannot_bypass_crc(tmp_path: Path) -> None:
|
||||||
|
capture = _capture_file(tmp_path / "inconsistent_accept.cs8")
|
||||||
|
candidate = offline.CoarseCandidate(10, 0.04)
|
||||||
|
|
||||||
|
def inconsistent(raw, sample_count, row, number):
|
||||||
|
del raw, sample_count, number
|
||||||
|
return _candidate_outcome(
|
||||||
|
row,
|
||||||
|
accepted=True,
|
||||||
|
next_sample=20,
|
||||||
|
crc=offline.lab046c.lab045.CRC_FAIL,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = offline.strict_process_candidates(
|
||||||
|
capture,
|
||||||
|
(candidate,),
|
||||||
|
decoder=inconsistent,
|
||||||
|
)
|
||||||
|
assert result.counters.crc_pass == 0
|
||||||
|
assert result.counters.crc_fail == 1
|
||||||
|
assert result.counters.valid_frames == 0
|
||||||
|
assert result.accepted_frames == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_weak_peak_only_enters_first_stage_without_crc(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
capture = _capture_file(tmp_path / "weak_candidate.cs8")
|
||||||
|
|
||||||
|
def blocks(raw, interval, chunk_samples):
|
||||||
|
del raw, interval, chunk_samples
|
||||||
|
scores = np.asarray([0.0, 0.04, 0.0], dtype=np.float32)
|
||||||
|
yield 0, scores, np.asarray([1]), 3, 100
|
||||||
|
|
||||||
|
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||||
|
rule = offline.CandidateRule(
|
||||||
|
"fixed",
|
||||||
|
0.999,
|
||||||
|
offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD,
|
||||||
|
)
|
||||||
|
coarse = offline.coarse_search_capture(capture, rule)
|
||||||
|
|
||||||
|
def reject(raw, sample_count, candidate, number):
|
||||||
|
del raw, sample_count, number
|
||||||
|
return _candidate_outcome(
|
||||||
|
candidate,
|
||||||
|
accepted=False,
|
||||||
|
next_sample=candidate.sample_offset + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
strict = offline.strict_process_candidates(
|
||||||
|
capture,
|
||||||
|
coarse.candidates,
|
||||||
|
decoder=reject,
|
||||||
|
)
|
||||||
|
assert len(coarse.candidates) == 1
|
||||||
|
assert strict.counters.valid_frames == 0
|
||||||
|
assert strict.counters.crc_pass == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("header", "reason"),
|
("header", "reason"),
|
||||||
[
|
[
|
||||||
@@ -404,6 +499,12 @@ def test_receiver_api_has_no_tx_or_expected_count_oracle() -> None:
|
|||||||
}
|
}
|
||||||
assert parameters.isdisjoint(forbidden)
|
assert parameters.isdisjoint(forbidden)
|
||||||
|
|
||||||
|
short_parameters = set(
|
||||||
|
inspect.signature(offline.run_1400mhz_independent_recovery).parameters
|
||||||
|
)
|
||||||
|
assert short_parameters == {"capture_path"}
|
||||||
|
assert short_parameters.isdisjoint(forbidden)
|
||||||
|
|
||||||
|
|
||||||
def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) -> None:
|
def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) -> None:
|
||||||
result = object.__new__(offline.IndependentRecoveryResult)
|
result = object.__new__(offline.IndependentRecoveryResult)
|
||||||
@@ -412,6 +513,38 @@ def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) ->
|
|||||||
offline.diagnose_after_independent_verdict(result, tmp_path)
|
offline.diagnose_after_independent_verdict(result, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_1400mhz_missing_frames_report_error_counts_as_unavailable(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
payload = bytes(256)
|
||||||
|
plan = offline.lab046c.lab045.build_transmission_plan(payload)
|
||||||
|
(tmp_path / "tx_payload_0.bin").write_bytes(payload)
|
||||||
|
(tmp_path / "tx_packet_0.bin").write_bytes(plan.packet)
|
||||||
|
independent = object.__new__(offline.IndependentRecoveryResult)
|
||||||
|
object.__setattr__(independent, "completed", True)
|
||||||
|
object.__setattr__(
|
||||||
|
independent,
|
||||||
|
"strict",
|
||||||
|
SimpleNamespace(accepted_frames=()),
|
||||||
|
)
|
||||||
|
diagnostics = offline.diagnose_1400mhz_after_independent_verdict(
|
||||||
|
independent,
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
assert diagnostics.matched_valid_frames == 0
|
||||||
|
assert diagnostics.missed_tx_frames == (0,)
|
||||||
|
assert diagnostics.total_pre_fec_coded_bit_errors is None
|
||||||
|
assert diagnostics.total_post_fec_protocol_bit_errors is None
|
||||||
|
assert diagnostics.total_payload_bit_errors is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_1400mhz_main_persists_independent_result_before_tx_reference() -> None:
|
||||||
|
source = inspect.getsource(offline.main)
|
||||||
|
write_position = source.index("write_1400mhz_independent_result")
|
||||||
|
diagnostic_position = source.index("diagnose_1400mhz_after_independent_verdict")
|
||||||
|
assert write_position < diagnostic_position
|
||||||
|
|
||||||
|
|
||||||
def test_safety_limited_independent_run_does_not_unlock_tx_reference(
|
def test_safety_limited_independent_run_does_not_unlock_tx_reference(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user