Lab046C: recover short 1400 MHz capture offline

This commit is contained in:
LittleSam129
2026-09-02 12:23:14 +03:00
parent 9b906c622d
commit d985030f04
4 changed files with 664 additions and 8 deletions

View File

@@ -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
без отдельной постановки запрещены.

View 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`.

View File

@@ -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({

View File

@@ -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,