From dacf04f2d0a15c0c1b56a7fb527db61fc2e38462 Mon Sep 17 00:00:00 2001 From: LittleSam129 <1qwe432@gmail.com> Date: Tue, 15 Sep 2026 13:42:08 +0300 Subject: [PATCH] Lab065: validate OTA gain improvement at TX VGA 20 dB --- PROJECT_LOG.md | 63 ++ data/processed/lab065/capture_result.json | 232 ++++++++ data/processed/lab065/preflight_result.json | 181 ++++++ data/processed/lab065/preparation.json | 219 +++++++ data/processed/lab065/rf_result.json | 515 ++++++++++++++++ data/processed/lab065/software_gate.json | 17 + docs/LABS_INDEX.md | 37 ++ docs/lab065_current_state.md | 70 +++ docs/lab065_rf_result.md | 114 ++++ docs/lab065_rx_preflight.md | 83 +++ experiments/lab065_rf_level_linearity.py | 623 ++++++++++++++++++++ tests/test_lab065_rf_level_linearity.py | 151 +++++ 12 files changed, 2305 insertions(+) create mode 100644 data/processed/lab065/capture_result.json create mode 100644 data/processed/lab065/preflight_result.json create mode 100644 data/processed/lab065/preparation.json create mode 100644 data/processed/lab065/rf_result.json create mode 100644 data/processed/lab065/software_gate.json create mode 100644 docs/lab065_current_state.md create mode 100644 docs/lab065_rf_result.md create mode 100644 docs/lab065_rx_preflight.md create mode 100644 experiments/lab065_rf_level_linearity.py create mode 100644 tests/test_lab065_rf_level_linearity.py diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 6359f84..c9aa34a 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -2861,3 +2861,66 @@ Quick gate после сеанса прошёл:986 тестов ядра,37 ф полное сравнение L3/L0 и двенадцать спектральных значений непосредственно из CS8. Расхождений нет, вердикт `чисто`. Оборудование при проверке не опрашивалось, RF TX остаётся равным нулю. + +# Запись 060 + +## Дата + +15 сентября 2026 года + +## Тема + +Lab064 зафиксирована; подготовка Lab065 при TX VGA 20 дБ и RX-only preflight + +## Выполнено + +После проверки уже подготовленного индекса по прямому разрешению пользователя +создан коммит `cff226e0bf9e36a627829ea11a4ca3292ae20953` с сообщением +`Lab064: measure OTA level linearity at TX VGA 10 dB`. В коммит вошли только +12 файлов и фрагментов Lab064; изменения Lab054 и Lab058 и сырые IQ не вошли. +Push не выполнялся. + +Пользователь подтвердил неизменность геометрии, антенн, поляризации, USB, +clock-кабеля и RF-кабелей. Для Lab065 TX VGA изменён с 10 до 20 дБ. Частота +1400000000 Гц, RX LNA/VGA 24/24 дБ, RF amplifier и Bias-Tee OFF, PRBS7/BPSK/RRC, +20000 символов/с, Fs 8000000 отсчётов/с, несущая +50000 Гц и лестница +−18/−12/−6/0 дБ сохранены. TX-файл побайтово совпадает с Lab064: 6000000 +отсчётов, 12000000 байт, 0,75 с, SHA-256 +fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9. + +Подготовлены RX-first runner без повторов, fail-closed проверка максимальных +счётчиков и анализ двух видов линейности: внутри Lab065 и для одинаковых +уровней Lab064/Lab065. До RF численно заданы границы пяти классификаций. +Software gate и 12 целевых тестов прошли. + +RX-only preflight прошёл. Обе HackRF One r9 и роли подтверждены, +MASTER/SLAVE reg29=0x26, SLAVE CLKIN=DETECTED на третьем опросе через201,986мс. +RX записал800000 отсчётов фона: полный RMS2,333068, AC RMS1,056565 кода, +max|I|/|Q|=6/8, clipping0, overruns0, неожиданно сильного сигнала нет. +Процессы до и после CLEAR, cleanup без ошибок. + +После отдельного прямого разрешения выполнен ровно один OTA-сеанс Lab065. +Перед TX MASTER/SLAVE reg29=0x26, SLAVE CLKIN=DETECTED на втором опросе через +136,897мс. RX стартовал раньше TX. TX underrun maximum0, RX overrun maximum0, +clipping0, cleanup без ошибок; процессы после CLEAR. RX:16000000 отсчётов, +2,0с, SHA-256 845d7b7dcaef70e179ab69e6ba0041601859630786d2f4d51c16a8c1c7869d7e. +Автоматических и ручных повторов не было. + +Внутренние ошибки ступеней Lab065 равны−0,077363/+0,034934/−0,013858дБ; +компрессии L2/L3 нет. Относительно Lab064 matched power вырос на +11,179328/11,119466/11,323670/11,287650дБ. Медианный прирост+11,233489дБ, +медианное изменение остатка+0,517004дБ, улучшение matched/residual+10,734495дБ. +Фон стабилен: разброс1,002840дБ при границе3дБ. Остаточные полосы50/100/150кГц +на L3 выросли на7,304593/6,432281/4,996232дБ, то есть слабее matched power. +Классификация `LINEAR_GAIN_IMPROVEMENT`. + +Независимая read-only проверка пересчитала RX SHA и размер, все счётчики, +локализацию, фон, matched/residual, комплексный h, CFO, внутренние ступени, +сравнение с Lab064 и полосы50/100/150кГц. Расхождений нет. Adversary подтвердил +одноразовость runner и нашёл неоднозначную подпись текущего состояния RF после +сеанса; она исправлена на `RF TX NOW=0` и закрыта отдельным тестом. Финальные +шлюзы: quick gate1000/38/13 PASS, pytest1000 passed, diff check PASS. + +RF TX=0. Lab065 не коммитировалась, push не выполнялся, Lab066 не создана. +Следующий этап: STOP, рассмотрение результата и отдельное разрешение на коммит +Lab065; повторная передача или изменение усилений запрещены. diff --git a/data/processed/lab065/capture_result.json b/data/processed/lab065/capture_result.json new file mode 100644 index 0000000..2905ae0 --- /dev/null +++ b/data/processed/lab065/capture_result.json @@ -0,0 +1,232 @@ +{ + "lab": "Lab065", + "stage": "SINGLE_RF_LEVEL_CAPTURE", + "passed": true, + "rf_tx_count": 1, + "automatic_tx_repeats": 0, + "point": { + "frequency_hz": 1400000000, + "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" + }, + "parameters": { + "rf_frequency_hz": 1400000000, + "sample_rate_hz": 8000000, + "symbol_rate": 20000, + "samples_per_symbol": 400, + "digital_carrier_hz": 50000, + "rrc_rolloff": 0.35, + "rrc_span_symbols": 10, + "prbs": "PRBS7 x^7+x^6+1, deterministic initial state", + "full_phy_packet": false, + "tx_vga_db": 20, + "rx_lna_db": 24, + "rx_vga_db": 24, + "rf_amplifier": false, + "bias_tee": false, + "geometry": { + "frequency_hz": 1400000000, + "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" + }, + "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", + "levels_db_relative_nominal": [ + -18.0, + -12.0, + -6.0, + 0.0 + ], + "geometry_unchanged_from_lab064": true, + "waveform_source": "Lab064 exact CS8 copy" + }, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "process_before": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 292, + "errors": [] + } + ] + }, + "inventory": { + "passed": true, + "serials": [ + "000000000000000097c063dc2e5327d3", + "000000000000000097c063dc323b97d3" + ], + "master_serial": "000000000000000097c063dc323b97d3", + "slave_serial": "000000000000000097c063dc2e5327d3", + "raw_output": "hackrf_info version: 2024.02.1\nlibhackrf version: 2024.02.1 (0.9)\nFound HackRF\nIndex: 0\nSerial number: 000000000000000097c063dc323b97d3\nBoard ID Number: 4 (HackRF One)\nFirmware Version: 2024.02.1 (API:1.08)\nPart ID Number: 0xa000cb3c 0x00744f5a\nHardware Revision: r9\nHardware does not appear to have been manufactured by Great Scott Gadgets.\nHardware supported by installed firmware:\n HackRF One\n\nFound HackRF\nIndex: 1\nSerial number: 000000000000000097c063dc2e5327d3\nBoard ID Number: 4 (HackRF One)\nFirmware Version: 2024.02.1 (API:1.08)\nPart ID Number: 0xa000cb3c 0xbc4b4739\nHardware Revision: r9\nHardware does not appear to have been manufactured by Great Scott Gadgets.\nHardware supported by installed firmware:\n HackRF One" + }, + "clock": { + "passed": true, + "master_reg29": 38, + "slave_reg29": 38, + "slave_clkin_status": "DETECTED", + "slave_clock_wait": { + "state": "DETECTED", + "queries": 2, + "time_to_detect_ms": 136.89709990285337, + "elapsed_ms": 136.89709990285337, + "polls": [ + { + "query": 1, + "start_ms": 50.08170008659363, + "end_ms": 67.4550998955965, + "raw": "CLKIN status: no clock signal detected", + "state": "NOT_DETECTED", + "exit_code": 0 + }, + { + "query": 2, + "start_ms": 117.92290001176298, + "end_ms": 136.89709990285337, + "raw": "CLKIN status: clock signal detected", + "state": "DETECTED", + "exit_code": 0 + } + ] + }, + "master_serial": "000000000000000097c063dc323b97d3", + "slave_serial": "000000000000000097c063dc2e5327d3" + }, + "rx_level_before_tx": { + "samples": 65536, + "mean_i": 0.9449310302734375, + "mean_q": 1.8423919677734375, + "power_code2": 4.87310791015625, + "ac_power_code2": 0.5858050952665499, + "rms_code": 2.2075117010236323, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.132239671543243, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "saturation_detected": false, + "analog_compression": "UNKNOWN_WITHOUT_CALIBRATION" + }, + "tx_process": { + "returncode": 0, + "stdout_file": "tx_stdout.txt", + "stderr_file": "tx_stderr.txt" + }, + "tx_counter_summary": { + "expected_kind": "underrun", + "observations": [ + { + "count": 0, + "role": "UNKNOWN", + "kind": "underrun", + "text": "0 underruns", + "character_offset": 337 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "underrun", + "text": "0 underruns", + "character_offset": 509 + } + ], + "selected_counts": [ + 0, + 0 + ], + "final_count": 0, + "maximum_count": 0, + "unparsed_loss_text": false, + "clean": true + }, + "rx_samples": 16000000, + "rx_bytes": 32000000, + "rx_duration_seconds": 2.0, + "rx_counter_summary": { + "expected_kind": "overrun", + "observations": [ + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 337 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 459 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 581 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 752 + } + ], + "selected_counts": [ + 0, + 0, + 0, + 0 + ], + "final_count": 0, + "maximum_count": 0, + "unparsed_loss_text": false, + "clean": true + }, + "rx_sha256": "845d7b7dcaef70e179ab69e6ba0041601859630786d2f4d51c16a8c1c7869d7e", + "rx_level": { + "samples": 16000000, + "mean_i": 0.942780625, + "mean_q": 1.8349653125, + "power_code2": 5.6163274375, + "ac_power_code2": 1.3603944325463857, + "rms_code": 2.3698792031451728, + "peak_magnitude_code": 10.816653826391969, + "max_abs_i": 10.0, + "max_abs_q": 10.0, + "papr_db": 13.187334420841685, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "saturation_detected": false, + "analog_compression": "UNKNOWN_WITHOUT_CALIBRATION" + }, + "rx_clipping": false, + "process_after": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 292, + "errors": [] + } + ] + }, + "cleanup_errors": [] +} diff --git a/data/processed/lab065/preflight_result.json b/data/processed/lab065/preflight_result.json new file mode 100644 index 0000000..73e9777 --- /dev/null +++ b/data/processed/lab065/preflight_result.json @@ -0,0 +1,181 @@ +{ + "lab": "Lab065", + "stage": "COMMON_CLOCK_AND_RX_ONLY_PREFLIGHT", + "passed": true, + "rf_tx_count": 0, + "stop_before_tx": true, + "point": { + "frequency_hz": 1400000000, + "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" + }, + "parameters": { + "rf_frequency_hz": 1400000000, + "sample_rate_hz": 8000000, + "symbol_rate": 20000, + "samples_per_symbol": 400, + "digital_carrier_hz": 50000, + "rrc_rolloff": 0.35, + "rrc_span_symbols": 10, + "prbs": "PRBS7 x^7+x^6+1, deterministic initial state", + "full_phy_packet": false, + "tx_vga_db": 20, + "rx_lna_db": 24, + "rx_vga_db": 24, + "rf_amplifier": false, + "bias_tee": false, + "geometry": { + "frequency_hz": 1400000000, + "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" + }, + "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", + "levels_db_relative_nominal": [ + -18.0, + -12.0, + -6.0, + 0.0 + ], + "geometry_unchanged_from_lab064": true, + "waveform_source": "Lab064 exact CS8 copy" + }, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "process_before": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 289, + "errors": [] + } + ] + }, + "inventory": { + "passed": true, + "serials": [ + "000000000000000097c063dc2e5327d3", + "000000000000000097c063dc323b97d3" + ], + "master_serial": "000000000000000097c063dc323b97d3", + "slave_serial": "000000000000000097c063dc2e5327d3", + "raw_output": "hackrf_info version: 2024.02.1\nlibhackrf version: 2024.02.1 (0.9)\nFound HackRF\nIndex: 0\nSerial number: 000000000000000097c063dc323b97d3\nBoard ID Number: 4 (HackRF One)\nFirmware Version: 2024.02.1 (API:1.08)\nPart ID Number: 0xa000cb3c 0x00744f5a\nHardware Revision: r9\nHardware does not appear to have been manufactured by Great Scott Gadgets.\nHardware supported by installed firmware:\n HackRF One\n\nFound HackRF\nIndex: 1\nSerial number: 000000000000000097c063dc2e5327d3\nBoard ID Number: 4 (HackRF One)\nFirmware Version: 2024.02.1 (API:1.08)\nPart ID Number: 0xa000cb3c 0xbc4b4739\nHardware Revision: r9\nHardware does not appear to have been manufactured by Great Scott Gadgets.\nHardware supported by installed firmware:\n HackRF One" + }, + "clock": { + "passed": true, + "master_reg29": 38, + "slave_reg29": 38, + "slave_clkin_status": "DETECTED", + "slave_clock_wait": { + "state": "DETECTED", + "queries": 3, + "time_to_detect_ms": 201.98559993878007, + "elapsed_ms": 201.98559993878007, + "polls": [ + { + "query": 1, + "start_ms": 50.617999862879515, + "end_ms": 66.89479993656278, + "raw": "CLKIN status: no clock signal detected", + "state": "NOT_DETECTED", + "exit_code": 0 + }, + { + "query": 2, + "start_ms": 117.23309988155961, + "end_ms": 136.1208998132497, + "raw": "CLKIN status: no clock signal detected", + "state": "NOT_DETECTED", + "exit_code": 0 + }, + { + "query": 3, + "start_ms": 187.1324998792261, + "end_ms": 201.98559993878007, + "raw": "CLKIN status: clock signal detected", + "state": "DETECTED", + "exit_code": 0 + } + ] + }, + "master_serial": "000000000000000097c063dc323b97d3", + "slave_serial": "000000000000000097c063dc2e5327d3" + }, + "rx_process": { + "returncode": 0, + "stdout": "", + "stderr": "call hackrf_set_sample_rate(8000000 Hz/8.000 MHz)\ncall hackrf_set_hw_sync_mode(0)\ncall hackrf_set_freq(1400000000 Hz/1400.000 MHz)\ncall hackrf_set_amp_enable(0)\ncall hackrf_set_antenna_enable(0)\nsamples_to_xfer 800000/0Mio\nStop with Ctrl-C\n 1.8 MiB / 0.116 sec = 15.9 MiB/second, average power -34.8 dBfs, 9024 bytes free in buffer, 0 overruns, longest 0 bytes\n\nExiting...\nTotal time: 0.11556 s\nhackrf_stop_rx() done\nTransfer statistics:\n1847520 bytes transferred by M0\n1835008 bytes transferred by M4\n0 overruns, longest 0 bytes\nhackrf_close() done\nhackrf_exit() done\nfclose() done\nexit\n" + }, + "rx_counter_summary": { + "expected_kind": "overrun", + "observations": [ + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 333 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 502 + } + ], + "selected_counts": [ + 0, + 0 + ], + "final_count": 0, + "maximum_count": 0, + "unparsed_loss_text": false, + "clean": true + }, + "rx_level": { + "samples": 800000, + "mean_i": 0.94889625, + "mean_q": 1.85107375, + "power_code2": 5.4432075, + "ac_power_code2": 1.1163293787968742, + "rms_code": 2.333068258752838, + "peak_magnitude_code": 8.06225774829855, + "max_abs_i": 6.0, + "max_abs_q": 8.0, + "papr_db": 10.770584663112091, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "saturation_detected": false, + "analog_compression": "UNKNOWN_WITHOUT_CALIBRATION", + "symbol_energy": "NOT_APPLICABLE_TO_BACKGROUND", + "ac_rms_code": 1.0565648956864289, + "unexpected_strong_signal": false + }, + "rx_sha256": "be3c61dfa1bc40d1a9f6777689026804a1ccfc340b82680d3ec5304ddd68f78c", + "rx_overruns": 0, + "process_after": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 289, + "errors": [] + } + ] + }, + "cleanup_errors": [] +} diff --git a/data/processed/lab065/preparation.json b/data/processed/lab065/preparation.json new file mode 100644 index 0000000..551d18b --- /dev/null +++ b/data/processed/lab065/preparation.json @@ -0,0 +1,219 @@ +{ + "lab": "Lab065", + "stage": "PREPARATION_ONLY", + "rf_tx_count": 0, + "head": "cff226e0bf9e36a627829ea11a4ca3292ae20953", + "parameters": { + "rf_frequency_hz": 1400000000, + "sample_rate_hz": 8000000, + "symbol_rate": 20000, + "samples_per_symbol": 400, + "digital_carrier_hz": 50000, + "rrc_rolloff": 0.35, + "rrc_span_symbols": 10, + "prbs": "PRBS7 x^7+x^6+1, deterministic initial state", + "full_phy_packet": false, + "tx_vga_db": 20, + "rx_lna_db": 24, + "rx_vga_db": 24, + "rf_amplifier": false, + "bias_tee": false, + "geometry": { + "frequency_hz": 1400000000, + "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" + }, + "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", + "levels_db_relative_nominal": [ + -18.0, + -12.0, + -6.0, + 0.0 + ], + "geometry_unchanged_from_lab064": true, + "waveform_source": "Lab064 exact CS8 copy" + }, + "segments": [ + { + "label": "L0", + "level_db": -18.0, + "start_sample": 800000, + "samples": 800000, + "duration_seconds": 0.1, + "analysis_start_sample": 808400, + "analysis_samples": 783200, + "metrics": { + "samples": 800000, + "mean_i": -9.625e-05, + "mean_q": 2.75e-05, + "power_code2": 30.87065625, + "ac_power_code2": 30.870656239979688, + "rms_code": 5.556136809870686, + "peak_magnitude_code": 9.219544457292887, + "max_abs_i": 9.0, + "max_abs_q": 9.0, + "papr_db": 4.398730638796093, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0015435328125, + "requested_relative_power_db": -18.0, + "tx_rms_code": 5.556136809870686, + "tx_peak_abs_i_code": 9.0, + "tx_peak_abs_q_code": 9.0, + "tx_peak_magnitude_code": 9.219544457292887, + "digital_clipping": false, + "actual_relative_power_db": -17.97368512020099 + } + }, + { + "label": "L1", + "level_db": -12.0, + "start_sample": 2000000, + "samples": 800000, + "duration_seconds": 0.1, + "analysis_start_sample": 2008400, + "analysis_samples": 783200, + "metrics": { + "samples": 800000, + "mean_i": -5e-05, + "mean_q": 5e-05, + "power_code2": 122.2567675, + "ac_power_code2": 122.25676749499999, + "rms_code": 11.056978226441435, + "peak_magnitude_code": 17.804493814764857, + "max_abs_i": 17.0, + "max_abs_q": 17.0, + "papr_db": 4.1378635346865025, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.006112838375, + "requested_relative_power_db": -12.0, + "tx_rms_code": 11.056978226441435, + "tx_peak_abs_i_code": 17.0, + "tx_peak_abs_q_code": 17.0, + "tx_peak_magnitude_code": 17.804493814764857, + "digital_clipping": false, + "actual_relative_power_db": -11.99641465105681 + }, + "tx_input_difference_from_previous_db": 5.9772704691441785 + }, + { + "label": "L2", + "level_db": -6.0, + "start_sample": 3200000, + "samples": 800000, + "duration_seconds": 0.1, + "analysis_start_sample": 3208400, + "analysis_samples": 783200, + "metrics": { + "samples": 800000, + "mean_i": 5.625e-05, + "mean_q": 2.625e-05, + "power_code2": 486.5064875, + "ac_power_code2": 486.50648749614686, + "rms_code": 22.05689206347984, + "peak_magnitude_code": 35.12833614050059, + "max_abs_i": 34.0, + "max_abs_q": 35.0, + "papr_db": 4.0422652379446395, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.024325324375, + "requested_relative_power_db": -6.0, + "tx_rms_code": 22.05689206347984, + "tx_peak_abs_i_code": 34.0, + "tx_peak_abs_q_code": 35.0, + "tx_peak_magnitude_code": 35.12833614050059, + "digital_clipping": false, + "actual_relative_power_db": -5.998257379520232 + }, + "tx_input_difference_from_previous_db": 5.998157271536577 + }, + { + "label": "L3", + "level_db": 0.0, + "start_sample": 4400000, + "samples": 800000, + "duration_seconds": 0.1, + "analysis_start_sample": 4408400, + "analysis_samples": 783200, + "metrics": { + "samples": 800000, + "mean_i": 8.25e-05, + "mean_q": 0.00011125, + "power_code2": 1936.04021375, + "ac_power_code2": 1936.0402137308172, + "rms_code": 44.00045697205883, + "peak_magnitude_code": 69.6419413859206, + "max_abs_i": 69.0, + "max_abs_q": 69.0, + "papr_db": 3.9882736474748155, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0968020106875, + "requested_relative_power_db": 0.0, + "tx_rms_code": 44.00045697205883, + "tx_peak_abs_i_code": 69.0, + "tx_peak_abs_q_code": 69.0, + "tx_peak_magnitude_code": 69.6419413859206, + "digital_clipping": false, + "actual_relative_power_db": 0.0 + }, + "tx_input_difference_from_previous_db": 5.998257379520232 + } + ], + "tx_samples": 6000000, + "tx_bytes": 12000000, + "tx_duration_seconds": 0.75, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "source_tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "waveform_unchanged_from_lab064": true, + "automatic_retry": false, + "tx_command_prepared_not_run": [ + "C:\\Users\\user\\Tools\\HackRF\\Library\\bin\\hackrf_transfer.exe", + "-d", + "000000000000000097c063dc323b97d3", + "-t", + "C:\\Users\\user\\Desktop\\projects\\SDR_Rover\\data\\raw\\lab065\\tx_level_staircase.cs8", + "-f", + "1400000000", + "-s", + "8000000", + "-n", + "6000000", + "-x", + "20", + "-a", + "0", + "-p", + "0", + "-B" + ], + "rx_command_prepared_not_run": [ + "C:\\Users\\user\\Tools\\HackRF\\Library\\bin\\hackrf_transfer.exe", + "-d", + "000000000000000097c063dc2e5327d3", + "-r", + "C:\\Users\\user\\Desktop\\projects\\SDR_Rover\\data\\raw\\lab065\\rx_stream.cs8", + "-f", + "1400000000", + "-s", + "8000000", + "-n", + "16000000", + "-l", + "24", + "-g", + "24", + "-a", + "0", + "-p", + "0", + "-B" + ] +} diff --git a/data/processed/lab065/rf_result.json b/data/processed/lab065/rf_result.json new file mode 100644 index 0000000..d8a098a --- /dev/null +++ b/data/processed/lab065/rf_result.json @@ -0,0 +1,515 @@ +{ + "lab": "Lab065", + "stage": "OFFLINE_RF_LEVEL_LINEARITY_ANALYSIS", + "packet_receiver_used": false, + "location": { + "start_sample": 4217216, + "correlation": 0.28566040369708917, + "coarse_cfo_hz": 0.0, + "stride": 32 + }, + "rows": [ + { + "label": "L0", + "level_db": -18.0, + "rx_start_sample": 5017215, + "alignment_adjustment_samples": -1, + "full_iq": { + "samples": 800000, + "mean_i": 0.9456825, + "mean_q": 1.849345, + "power_code2": 5.91858, + "ac_power_code2": 1.6041876801687494, + "rms_code": 2.4328131864160882, + "peak_magnitude_code": 7.280109889280518, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 9.520583473606806, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000295929, + "ac_rms_code": 1.2665653082919766 + }, + "background_label": "INITIAL_GUARD", + "background": { + "samples": 160000, + "mean_i": 0.9375625, + "mean_q": 1.83864375, + "power_code2": 4.84290625, + "ac_power_code2": 0.5832719691796884, + "rms_code": 2.200660412239926, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.1592393370019245, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00024214531250000003, + "ac_rms_code": 0.7637224425009969 + }, + "matched_signal_power": 0.009849829325472823, + "residual_power": 1.147671646182205, + "matched_to_residual_db": -20.663889470509574, + "complex_gain_h": { + "real": -0.0069147093491316635, + "imag": 0.016278708105995708, + "phase_rad_at_active_start": 1.9724722764994853 + }, + "gain_abs": 0.01768642256034779, + "gain_power_abs_h_squared": 0.00031280954298317925, + "cfo_hz": 0.051385857393477286, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.005700729949335216, + "residual_band_h2_power_code2": 0.004639110794845057, + "residual_band_h3_power_code2": 0.003007515042889266, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L1", + "level_db": -12.0, + "rx_start_sample": 6217220, + "alignment_adjustment_samples": 4, + "full_iq": { + "samples": 800000, + "mean_i": 0.943035, + "mean_q": 1.84948125, + "power_code2": 5.45620125, + "ac_power_code2": 1.1463053446734381, + "rms_code": 2.335851290215197, + "peak_magnitude_code": 7.810249675906654, + "max_abs_i": 7.0, + "max_abs_q": 7.0, + "papr_db": 10.48439454272797, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00027281006250000003, + "ac_rms_code": 1.0706565017191265 + }, + "background_label": "GAP_L0_L1", + "background": { + "samples": 160000, + "mean_i": 0.94049375, + "mean_q": 1.8457, + "power_code2": 4.89708125, + "ac_power_code2": 0.6059442662109387, + "rms_code": 2.2129349854887286, + "peak_magnitude_code": 5.0990195135927845, + "max_abs_i": 4.0, + "max_abs_q": 5.0, + "papr_db": 7.250360382891476, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002448540625, + "ac_rms_code": 0.7784242199539648 + }, + "matched_signal_power": 0.038319468321711674, + "residual_power": 0.6202840436902609, + "matched_to_residual_db": -12.091711347697867, + "complex_gain_h": { + "real": -0.010179637303215595, + "imag": 0.014270629544800722, + "phase_rad_at_active_start": 2.1904112551609387 + }, + "gain_abs": 0.017529286443833295, + "gain_power_abs_h_squared": 0.00030727588322995773, + "cfo_hz": -0.015456037277818255, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.0075272705585461884, + "residual_band_h2_power_code2": 0.005178438128918921, + "residual_band_h3_power_code2": 0.0030044589971250603, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L2", + "level_db": -6.0, + "rx_start_sample": 7417219, + "alignment_adjustment_samples": 3, + "full_iq": { + "samples": 800000, + "mean_i": 0.94256, + "mean_q": 1.85174125, + "power_code2": 5.55749625, + "ac_power_code2": 1.2401312394484378, + "rms_code": 2.3574342514691686, + "peak_magnitude_code": 7.280109889280518, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 9.793966912760688, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002778748125, + "ac_rms_code": 1.1136117992588073 + }, + "background_label": "GAP_L1_L2", + "background": { + "samples": 160000, + "mean_i": 0.94511875, + "mean_q": 1.8435375, + "power_code2": 4.89561875, + "ac_power_code2": 0.6037387844921858, + "rms_code": 2.2126045173053406, + "peak_magnitude_code": 8.06225774829855, + "max_abs_i": 7.0, + "max_abs_q": 7.0, + "papr_db": 11.231057671986083, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002447809375, + "ac_rms_code": 0.7770062963015073 + }, + "matched_signal_power": 0.15371936701352645, + "residual_power": 0.7099975700355287, + "matched_to_residual_db": -6.645282748943733, + "complex_gain_h": { + "real": -0.00913356439809709, + "imag": 0.015044605957089683, + "phase_rad_at_active_start": 2.1164192954656 + }, + "gain_abs": 0.01760006156291179, + "gain_power_abs_h_squared": 0.000309762167018285, + "cfo_hz": -0.02238057704898943, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.016197144376941752, + "residual_band_h2_power_code2": 0.007835153285271243, + "residual_band_h3_power_code2": 0.003956469302302745, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L3", + "level_db": 0.0, + "rx_start_sample": 8617220, + "alignment_adjustment_samples": 4, + "full_iq": { + "samples": 800000, + "mean_i": 0.941795, + "mean_q": 1.85647375, + "power_code2": 6.38914875, + "ac_power_code2": 2.055676143535938, + "rms_code": 2.52767655169723, + "peak_magnitude_code": 8.602325267042627, + "max_abs_i": 7.0, + "max_abs_q": 8.0, + "papr_db": 10.637887203839098, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00031945743750000003, + "ac_rms_code": 1.4337629314276255 + }, + "background_label": "GAP_L2_L3", + "background": { + "samples": 160000, + "mean_i": 0.9481, + "mean_q": 1.8589625, + "power_code2": 4.9655625, + "ac_power_code2": 0.6109273135937494, + "rms_code": 2.228354213315289, + "peak_magnitude_code": 6.324555320336759, + "max_abs_i": 5.0, + "max_abs_q": 6.0, + "papr_db": 9.060915387364618, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000248278125, + "ac_rms_code": 0.7816183938430246 + }, + "matched_signal_power": 0.609773488771332, + "residual_power": 1.8217771923731447, + "matched_to_residual_db": -4.753267221075902, + "complex_gain_h": { + "real": -0.009107358713761582, + "imag": 0.015027742846344474, + "phase_rad_at_active_start": 2.1156425161969685 + }, + "gain_abs": 0.017572052748526183, + "gain_power_abs_h_squared": 0.0003087770377969866, + "cfo_hz": -0.006443832004222744, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.05148134451389538, + "residual_band_h2_power_code2": 0.019651663054535452, + "residual_band_h3_power_code2": 0.008610269373925542, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + } + ], + "background_windows": [ + { + "label": "INITIAL_GUARD", + "tx_relative_start_sample": 320000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 4537216, + "metrics": { + "samples": 160000, + "mean_i": 0.9375625, + "mean_q": 1.83864375, + "power_code2": 4.84290625, + "ac_power_code2": 0.5832719691796884, + "rms_code": 2.200660412239926, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.1592393370019245, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00024214531250000003, + "ac_rms_code": 0.7637224425009969 + } + }, + { + "label": "GAP_L0_L1", + "tx_relative_start_sample": 1760000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 5977216, + "metrics": { + "samples": 160000, + "mean_i": 0.94049375, + "mean_q": 1.8457, + "power_code2": 4.89708125, + "ac_power_code2": 0.6059442662109387, + "rms_code": 2.2129349854887286, + "peak_magnitude_code": 5.0990195135927845, + "max_abs_i": 4.0, + "max_abs_q": 5.0, + "papr_db": 7.250360382891476, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002448540625, + "ac_rms_code": 0.7784242199539648 + } + }, + { + "label": "GAP_L1_L2", + "tx_relative_start_sample": 2960000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 7177216, + "metrics": { + "samples": 160000, + "mean_i": 0.94511875, + "mean_q": 1.8435375, + "power_code2": 4.89561875, + "ac_power_code2": 0.6037387844921858, + "rms_code": 2.2126045173053406, + "peak_magnitude_code": 8.06225774829855, + "max_abs_i": 7.0, + "max_abs_q": 7.0, + "papr_db": 11.231057671986083, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002447809375, + "ac_rms_code": 0.7770062963015073 + } + }, + { + "label": "GAP_L2_L3", + "tx_relative_start_sample": 4160000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 8377216, + "metrics": { + "samples": 160000, + "mean_i": 0.9481, + "mean_q": 1.8589625, + "power_code2": 4.9655625, + "ac_power_code2": 0.6109273135937494, + "rms_code": 2.228354213315289, + "peak_magnitude_code": 6.324555320336759, + "max_abs_i": 5.0, + "max_abs_q": 6.0, + "papr_db": 9.060915387364618, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000248278125, + "ac_rms_code": 0.7816183938430246 + } + }, + { + "label": "FINAL_GUARD", + "tx_relative_start_sample": 5440000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 9657216, + "metrics": { + "samples": 160000, + "mean_i": 0.94553125, + "mean_q": 1.8361, + "power_code2": 5.00006875, + "ac_power_code2": 0.7347761952734375, + "rms_code": 2.2360833504142907, + "peak_magnitude_code": 9.486832980505138, + "max_abs_i": 9.0, + "max_abs_q": 9.0, + "papr_db": 12.55266533595234, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002500034375, + "ac_rms_code": 0.8571908744692966 + } + } + ], + "background_summary": { + "ac_power_min_code2": 0.5832719691796884, + "ac_power_max_code2": 0.7347761952734375, + "ac_rms_min_code": 0.7637224425009969, + "ac_rms_max_code": 0.8571908744692966, + "maximum_to_minimum_ac_power_db": 1.0028397226073626, + "stability_rule_db": 3.0, + "stable": true + }, + "comparisons": [ + { + "comparison": "L1/L0", + "tx_input_difference_db": 5.9772704691441785, + "rx_matched_power_difference_db": 5.899907694072936, + "linearity_error_db": -0.07736277507124267 + }, + { + "comparison": "L2/L1", + "tx_input_difference_db": 5.998157271536577, + "rx_matched_power_difference_db": 6.033091127942342, + "linearity_error_db": 0.03493385640576463 + }, + { + "comparison": "L3/L2", + "tx_input_difference_db": 5.998257379520232, + "rx_matched_power_difference_db": 5.9843995108689985, + "linearity_error_db": -0.013857868651233396 + } + ], + "capture_valid": true, + "capture_invalid_reasons": [], + "tx_underruns_maximum": 0, + "rx_overruns_maximum": 0, + "rx_clipping": false, + "lab064_to_lab065": { + "rows": [ + { + "label": "L0", + "tx_vga_nominal_difference_db": 10.0, + "rx_matched_power_difference_db": 11.179327767173566, + "gain_power_abs_h_squared_difference_db": 11.179327767173568, + "residual_power_difference_db": -0.24327621738947727, + "matched_to_residual_improvement_db": 11.422603984563047, + "complex_gain_h_old": { + "real": 0.0018760642981310563, + "imag": -0.0045080629571906915, + "phase_rad_at_active_start": -1.1764391150184978 + }, + "complex_gain_h_new": { + "real": -0.0069147093491316635, + "imag": 0.016278708105995708, + "phase_rad_at_active_start": 1.9724722764994853 + }, + "complex_gain_phase_change_degrees": -179.5806672053524, + "residual_spectral_band_difference_db": { + "h1_50000_hz": 2.869745130254167, + "h2_100000_hz": 2.6545689216728405, + "h3_150000_hz": 2.25865088789477 + } + }, + { + "label": "L1", + "tx_vga_nominal_difference_db": 10.0, + "rx_matched_power_difference_db": 11.119465772157373, + "gain_power_abs_h_squared_difference_db": 11.119465772157373, + "residual_power_difference_db": -2.3662859353114283, + "matched_to_residual_improvement_db": 13.4857517074688, + "complex_gain_h_old": { + "real": 0.004471119012843702, + "imag": -0.001937685853593986, + "phase_rad_at_active_start": -0.40894566669212534 + }, + "complex_gain_h_new": { + "real": -0.010179637303215595, + "imag": 0.014270629544800722, + "phase_rad_at_active_start": 2.1904112551609387 + }, + "complex_gain_phase_change_degrees": 148.9321810702975, + "residual_spectral_band_difference_db": { + "h1_50000_hz": 3.0326799855695237, + "h2_100000_hz": 2.378560725133286, + "h3_150000_hz": 1.3007160057992286 + } + }, + { + "label": "L2", + "tx_vga_nominal_difference_db": 10.0, + "rx_matched_power_difference_db": 11.323669836462136, + "gain_power_abs_h_squared_difference_db": 11.323669836462136, + "residual_power_difference_db": 1.2772838599246652, + "matched_to_residual_improvement_db": 10.046385976537472, + "complex_gain_h_old": { + "real": 0.0032772531007889995, + "imag": -0.003478187284280655, + "phase_rad_at_active_start": -0.8151334504253348 + }, + "complex_gain_h_new": { + "real": -0.00913356439809709, + "imag": 0.015044605957089683, + "phase_rad_at_active_start": 2.1164192954656 + }, + "complex_gain_phase_change_degrees": 167.96559975953804, + "residual_spectral_band_difference_db": { + "h1_50000_hz": 5.594944314904455, + "h2_100000_hz": 4.091263491385863, + "h3_150000_hz": 2.888396722883724 + } + }, + { + "label": "L3", + "tx_vga_nominal_difference_db": 10.0, + "rx_matched_power_difference_db": 11.287649575482401, + "gain_power_abs_h_squared_difference_db": 11.287649575482403, + "residual_power_difference_db": 3.907580735290519, + "matched_to_residual_improvement_db": 7.380068840191884, + "complex_gain_h_old": { + "real": 0.003384387504328541, + "imag": -0.003391322182689881, + "phase_rad_at_active_start": -0.7864216245934512 + }, + "complex_gain_h_new": { + "real": -0.009107358713761582, + "imag": 0.015027742846344474, + "phase_rad_at_active_start": 2.1156425161969685 + }, + "complex_gain_phase_change_degrees": 166.2760271435506, + "residual_spectral_band_difference_db": { + "h1_50000_hz": 7.304592641009381, + "h2_100000_hz": 6.432280867349566, + "h3_150000_hz": 4.996231985221501 + } + } + ], + "rules_fixed_before_rf": { + "internal_linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "meaningful_matched_gain_median_at_least_db": 3.0, + "meaningful_ratio_improvement_median_at_least_db": 3.0, + "maximum_level_to_level_gain_spread_db": 3.0, + "residual_tracks_if_within_matched_gain_db": 3.0 + } + }, + "classification": { + "case": "LINEAR_GAIN_IMPROVEMENT", + "summary": { + "median_matched_gain_db": 11.233488671327983, + "median_residual_gain_db": 0.5170038212675939, + "median_matched_to_residual_improvement_db": 10.73449498055026, + "matched_gain_level_spread_db": 0.20420406430476312, + "maximum_internal_linearity_error_db": 0.07736277507124267, + "upper_step_linearity_error_db": -0.013857868651233396 + }, + "rules_fixed_before_rf": { + "internal_linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "meaningful_matched_gain_median_at_least_db": 3.0, + "meaningful_ratio_improvement_median_at_least_db": 3.0, + "maximum_level_to_level_gain_spread_db": 3.0, + "residual_tracks_if_within_matched_gain_db": 3.0 + } + } +} diff --git a/data/processed/lab065/software_gate.json b/data/processed/lab065/software_gate.json new file mode 100644 index 0000000..4fbc5a3 --- /dev/null +++ b/data/processed/lab065/software_gate.json @@ -0,0 +1,17 @@ +{ + "lab": "Lab065", + "stage": "SOFTWARE_GATE", + "rf_tx_count": 0, + "passed": true, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "waveform_unchanged_from_lab064": true, + "lab064_baseline_classification": "LINEAR_BUT_WEAK", + "classification_rules": { + "internal_linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "meaningful_matched_gain_median_at_least_db": 3.0, + "meaningful_ratio_improvement_median_at_least_db": 3.0, + "maximum_level_to_level_gain_spread_db": 3.0, + "residual_tracks_if_within_matched_gain_db": 3.0 + } +} diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index e79d5e9..1bcbec5 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -663,3 +663,40 @@ - **Независимая проверка:** прямой пересчёт из TX/RX CS8 подтвердил все SHA, счётчики, фоны, matched/residual/`h`, ступени и спектральные полосы; расхождений нет. Quick gate и pytest после RF:986 passed,0 failed. + +## Lab065: измерение уровня при TX VGA 20 дБ + +- **База:** Lab064 зафиксирована коммитом + `cff226e0bf9e36a627829ea11a4ca3292ae20953`; push не выполнялся. +- **Единственное изменение параметра:** TX VGA 10 → 20 дБ. Частота 1400 МГц, + RX LNA/VGA 24/24 дБ, усилители, геометрия, антенны и кабели сохранены. +- **Waveform:** точная копия Lab064, 6000000 отсчётов, 0,75 с; SHA-256 + `fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9`. +- **Подготовлено:** RX-first runner, максимальные счётчики потерь, сравнение + линейности внутри Lab065 и по одинаковым уровням Lab064/Lab065, численные + правила классификации. Software gate PASS. +- **RX-only preflight:** обе HackRF и роли подтверждены, reg29=0x26 у MASTER и + SLAVE, CLKIN обнаружен на третьем опросе через201,986мс. Фон:800000 отсчётов, + AC RMS1,056565 кода, max|I|/|Q|=6/8, clipping0, overruns0. Cleanup PASS. +- **Граница:** `RF TX=0`. Lab065 не коммитировалась. Нужны рассмотрение + подготовки и отдельное прямое разрешение на один TX; Lab066 не начинать. +- [Текущее состояние](lab065_current_state.md), + [RX-only отчёт](lab065_rx_preflight.md), + [подготовка](../data/processed/lab065/preparation.json), + [software gate](../data/processed/lab065/software_gate.json), + [preflight](../data/processed/lab065/preflight_result.json). +- **Единственный RF-сеанс:** TX1, автоматических повторов0; underrun/overrun/ + clipping0, cleanup PASS, процессы CLEAR. RX16000000 отсчётов, SHA-256 + `845d7b7dcaef70e179ab69e6ba0041601859630786d2f4d51c16a8c1c7869d7e`. +- **Линейность:** ошибки ступеней −0,077363/+0,034934/−0,013858дБ, компрессии + L2/L3 нет. Медианный прирост matched power+11,233489дБ, изменение остатка + +0,517004дБ, улучшение matched/residual+10,734495дБ. +- **Результат:** `LINEAR_GAIN_IMPROVEMENT`. Фон стабилен; диагностические полосы + остатка растут слабее matched power. [RF-отчёт](lab065_rf_result.md), + [RF JSON](../data/processed/lab065/rf_result.json), + [capture JSON](../data/processed/lab065/capture_result.json). +- **STOP:** `RF TX=0`; повторов и изменения усилений не было. Lab065 не + коммитировалась, push отсутствует, Lab066 не создана. +- **Независимая проверка:** прямой пересчёт CS8 подтвердил SHA, счётчики, + локализацию, фон, matched/residual, `h`, CFO и спектральные полосы. Расхождений + нет. Quick gate1000/38/13 PASS, pytest1000 passed, diff check PASS. diff --git a/docs/lab065_current_state.md b/docs/lab065_current_state.md new file mode 100644 index 0000000..65ebe59 --- /dev/null +++ b/docs/lab065_current_state.md @@ -0,0 +1,70 @@ +# Lab065: подготовка измерения при TX VGA 20 дБ + +Lab065 повторяет измерение Lab064 с одним изменением аппаратного параметра: +TX VGA повышен с 10 до 20 дБ. Частота 1400000000 Гц, RX LNA/VGA 24/24 дБ, +геометрия, антенны, кабели, общая опора и цифровой waveform сохранены. + +TX-файл Lab065 является побайтовой копией Lab064. Его SHA-256 должен быть равен +`fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9`. +Длина остаётся равной 6000000 комплексных отсчётов и 0,75 с. Ступени +−18/−12/−6/0 дБ, PRBS7, BPSK, RRC, 20000 символов/с, Fs 8000000 отсчётов/с и +цифровая несущая +50000 Гц не меняются. + +## Сравнение с Lab064 + +Для L0, L1, L2 и L3 анализатор выдаёт изменение согласованной мощности, +`|h|²`, остаточной мощности и matched/residual. Номинальное изменение TX VGA +равно +10 дБ и приводится как справочная величина, а не как условие PASS. + +До RF зафиксированы численные правила: + +- линейность внутри Lab065: максимальная абсолютная ошибка шага не более 1 дБ; +- компрессия: ошибка верхнего шага не более −1,5 дБ; +- заметный прирост: медиана прироста matched power не менее 3 дБ; +- заметное улучшение matched/residual: медиана не менее 3 дБ; +- согласованность прироста четырёх уровней: разброс не более 3 дБ; +- остаток считается растущим почти как сигнал, если его медианный прирост + отстаёт от медианного прироста matched power не более чем на 3 дБ. + +Эти пороги нужны для воспроизводимой классификации конкретного опыта. Они не +заменяют измерение абсолютной RF-мощности. + +## Аппаратная граница + +Подготовка допускает программные проверки, common-clock preflight и RX-only +запись фона. Любая команда с `-t` внутри preflight запрещена. Передача Lab065 +требует нового прямого разрешения пользователя и в текущий этап не входит. +Автоматических повторов и изменения усилений после ошибки нет. + +`RF TX = 0`. + +## Результат подготовки + +Подготовка выполнена от HEAD +`cff226e0bf9e36a627829ea11a4ca3292ae20953`. Software gate прошёл. Побайтовая +копия waveform имеет требуемый SHA-256, подготовленная TX-команда содержит +TX VGA 20 дБ, а RX-команда сохраняет LNA/VGA 24/24 дБ. + +Common-clock и RX-only preflight прошёл. Обе HackRF One r9 обнаружены с +прежними ролями. MASTER и SLAVE имеют reg29 `0x26`; SLAVE CLKIN обнаружен на +третьем опросе через 201,986 мс. В RX-фоне из 800000 отсчётов AC RMS равен +1,056565 кода, max |I|/|Q| равны 6/8, clipping и overruns отсутствуют. +Процессы до и после проверки имеют состояние CLEAR, ошибок очистки нет. + +Lab065 готова к рассмотрению перед одним RF-сеансом. Текущая подготовка такого +разрешения не содержит. `RF TX = 0`. + +## Единственный RF-сеанс + +После отдельного прямого разрешения выполнен один TX. Общая опора прошла: +MASTER/SLAVE reg29 `0x26`, SLAVE CLKIN обнаружен на втором опросе через +136,897 мс. TX underrun, RX overrun, clipping и ошибки очистки отсутствуют. +Повторов не было. RX содержит 16000000 отсчётов, SHA-256 +`845d7b7dcaef70e179ab69e6ba0041601859630786d2f4d51c16a8c1c7869d7e`. + +Медианный прирост matched power относительно Lab064 равен +11,233489 дБ, +медианное изменение остатка +0,517004 дБ, улучшение matched/residual ++10,734495 дБ. Ошибки ступеней равны −0,077363/+0,034934/−0,013858 дБ. +Признаков компрессии нет. Классификация: `LINEAR_GAIN_IMPROVEMENT`. + +[Полный RF-отчёт](lab065_rf_result.md). `RF TX = 0`. diff --git a/docs/lab065_rf_result.md b/docs/lab065_rf_result.md new file mode 100644 index 0000000..4ce9097 --- /dev/null +++ b/docs/lab065_rf_result.md @@ -0,0 +1,114 @@ +# Lab065: один OTA-сеанс при TX VGA 20 дБ + +15 сентября 2026 года выполнен один разрешённый OTA-сеанс Lab065 на частоте +1400000000 Гц. Передача использовала точную копию waveform Lab064 с SHA-256 +`fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9`. +Автоматических и ручных повторов не было. + +## Аппаратная валидность + +Перед TX обе HackRF One r9 и их роли подтверждены. MASTER и SLAVE имели reg29 +`0x26`; SLAVE CLKIN обнаружен на втором опросе через 136,897 мс. RX стартовал +раньше TX. Результат сеанса: + +```text +COMPLETED TX SESSIONS = 1 +AUTOMATIC TX REPEATS = 0 +TX UNDERRUN MAXIMUM = 0 +RX OVERRUN MAXIMUM = 0 +RX CLIPPING = NO +CLEANUP ERRORS = [] +SDR PROCESSES = CLEAR +``` + +RX содержит 16000000 комплексных отсчётов, 32000000 байт и 2,0 с. SHA-256: +`845d7b7dcaef70e179ab69e6ba0041601859630786d2f4d51c16a8c1c7869d7e`. +Максимумы всей записи равны `|I|=10` и `|Q|=10`; rail-кодов нет. + +## Линейность внутри Lab065 + +| Переход | TX, дБ | RX matched, дБ | Ошибка, дБ | +|---|---:|---:|---:| +| L0 → L1 | +5,977270 | +5,899908 | −0,077363 | +| L1 → L2 | +5,998157 | +6,033091 | +0,034934 | +| L2 → L3 | +5,998257 | +5,984400 | −0,013858 | + +Максимальная абсолютная ошибка равна 0,077363 дБ при границе 1 дБ. Ошибка +верхней ступени равна −0,013858 дБ при пороге компрессии −1,5 дБ. Согласованная +мощность L3 выросла относительно L2 на 5,984400 дБ. Признаков компрессии на +L2/L3 нет. + +## Сравнение Lab064 и Lab065 + +| Уровень | Matched, дБ | Остаток, дБ | Улучшение matched/residual, дБ | Изменение \|h\|², дБ | +|---|---:|---:|---:|---:| +| L0 | +11,179328 | −0,243276 | +11,422604 | +11,179328 | +| L1 | +11,119466 | −2,366286 | +13,485752 | +11,119466 | +| L2 | +11,323670 | +1,277284 | +10,046386 | +11,323670 | +| L3 | +11,287650 | +3,907581 | +7,380069 | +11,287650 | + +Медианный прирост согласованной мощности равен +11,233489 дБ. Его разброс по +четырём уровням составляет 0,204204 дБ. Медианное изменение остатка равно ++0,517004 дБ, а медианное улучшение matched/residual равно +10,734495 дБ. + +При TX VGA 20 дБ matched/residual для L0/L1/L2/L3 равен +−20,663889/−12,091711/−6,645283/−4,753267 дБ. Отношение улучшилось на каждом +уровне, хотя даже на L3 согласованная составляющая остаётся слабее остатка. + +## Комплексный коэффициент передачи + +| Уровень | h Lab064 | h Lab065 | \|h\| Lab064 | \|h\| Lab065 | Изменение фазы, градусы | +|---|---|---|---:|---:|---:| +| L0 | 0,001876 − j0,004508 | −0,006915 + j0,016279 | 0,004883 | 0,017686 | −179,581 | +| L1 | 0,004471 − j0,001938 | −0,010180 + j0,014271 | 0,004873 | 0,017529 | +148,932 | +| L2 | 0,003277 − j0,003478 | −0,009134 + j0,015045 | 0,004779 | 0,017600 | +167,966 | +| L3 | 0,003384 − j0,003391 | −0,009107 + j0,015028 | 0,004791 | 0,017572 | +166,276 | + +Модуль `h` внутри Lab065 почти постоянен: 0,017529…0,017686. Его мощность +относительно Lab064 выросла на 11,119…11,324 дБ, что согласуется с matched +power. Фаза между отдельными сеансами не имеет непрерывной временной привязки, +поэтому приведённый поворот является диагностикой конкретных записей, а не +калиброванной фазовой характеристикой тракта. + +## Фон и остаточные полосы + +Пять нулевых окон Lab065 имеют AC RMS 0,763722…0,857191 кода. Разброс фоновой +AC-мощности равен 1,002840 дБ при границе 3 дБ, поэтому фон классифицирован как +стабильный. В Lab064 разброс составлял 0,119530 дБ; фон Lab065 стабильный, но +межоконный разброс стал больше. + +Изменение диагностических полос остатка относительно Lab064: + +| Уровень | 50 кГц, дБ | 100 кГц, дБ | 150 кГц, дБ | +|---|---:|---:|---:| +| L0 | +2,869745 | +2,654569 | +2,258651 | +| L1 | +3,032680 | +2,378561 | +1,300716 | +| L2 | +5,594944 | +4,091263 | +2,888397 | +| L3 | +7,304593 | +6,432281 | +4,996232 | + +На верхних уровнях остаточные полосы выросли, особенно около 50 кГц. На L3 их +рост остаётся ниже прироста matched power +11,287650 дБ, а matched/residual +улучшился на 7,380069 дБ. Эти цифровые полосы не являются калиброванным +измерением RF-гармоник. + +## Итог + +```text +CLASSIFICATION = LINEAR_GAIN_IMPROVEMENT +``` + +Повышение TX VGA с 10 до 20 дБ дало устойчивый прирост согласованной принятой +мощности примерно на 11,2 дБ без потери линейности. Остаток вырос существенно +меньше, поэтому отношение полезной составляющей к остатку улучшилось на всех +четырёх уровнях. Верхняя ступень не показывает компрессии. + +Результат остаётся относительным измерением всего OTA-тракта. Он не определяет +абсолютную RF-мощность и не доказывает прохождение полного PHY-пакета, FEC, CRC, +T2 или JPEG. + +Независимая проверка пересчитала сохранённый CS8, локализацию, фон, matched, +residual, `h`, CFO, внутреннюю линейность и сравнение с Lab064. Расхождений нет. +Рекомендуемый следующий одиночный шаг: рассмотреть scope Lab065 и отдельно +разрешить локальный коммит. Новую RF-передачу для этого выполнять не требуется. + +`RF TX = 0`. Lab065 не коммитировалась, push не выполнялся, Lab066 не создана. diff --git a/docs/lab065_rx_preflight.md b/docs/lab065_rx_preflight.md new file mode 100644 index 0000000..6e5ff36 --- /dev/null +++ b/docs/lab065_rx_preflight.md @@ -0,0 +1,83 @@ +# Lab065: подготовка и RX-only preflight + +15 сентября 2026 года подготовлена Lab065 с TX VGA 20 дБ. Пользователь +подтвердил неизменность расстояния около 1,2 м, прямой видимости, положения TX +возле окна и RX на столе, антенн, поляризации, USB, общей опоры и RF-кабелей. + +## Неизменность сигнала + +TX-файл Lab065 является побайтовой копией файла Lab064: + +```text +TX samples = 6000000 +TX bytes = 12000000 +TX duration = 0.750000 s +TX SHA-256 = fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9 +WAVEFORM UNCHANGED FROM LAB064 = YES +``` + +Сохранены PRBS7, BPSK, RRC, ступени −18/−12/−6/0 дБ, интервалы и нулевые +защитные участки, 20000 символов/с, Fs 8000000 комплексных отсчётов/с и +цифровая несущая +50000 Гц. Из аппаратных параметров изменён только TX VGA: +10 → 20 дБ. RX LNA/VGA остаются равными 24/24 дБ; RF amplifier и Bias-Tee +выключены. + +Software gate прошёл. Он подтвердил SHA, пригодность результата Lab064 как +базы сравнения, синтетическую проверку линейности и наличие численных правил +классификации, зафиксированных до RF. + +## Common clock + +Перед аппаратными командами проверка процессов через psutil осмотрела 289 +процессов и получила CLEAR. Обнаружены обе HackRF One r9 с firmware и +libhackrf 2024.02.1. Роли совпали с Lab064: + +- MASTER `000000000000000097c063dc323b97d3`; +- SLAVE `000000000000000097c063dc2e5327d3`. + +Результат общей опоры: + +```text +MASTER reg29 = 0x26 +SLAVE reg29 = 0x26 +SLAVE CLKIN = DETECTED +CLKIN query = 3 +time to detect = 201.986 ms +``` + +Первые два опроса не обнаружили CLKIN, третий опрос прошёл в пределах штатного +окна ожидания. Итоговый common-clock gate имеет статус PASS. + +## RX-only фон + +SLAVE записал 800000 комплексных отсчётов при 1400000000 Гц и RX LNA/VGA +24/24 дБ. + +| Метрика | Значение | +|---|---:| +| Полный RMS IQ | 2,333068 кода | +| AC RMS после удаления среднего | 1,056565 кода | +| Средние I / Q | 0,948896 / 1,851074 кода | +| max \|I\| / \|Q\| | 6 / 8 кодов | +| Максимальная комплексная амплитуда | 8,062258 кода | +| Clipping | 0 / 800000 отсчётов | +| RX overruns, максимум / финал | 0 / 0 | +| Неожиданно сильный сигнал | нет | + +SHA-256 RX-фона: +`be3c61dfa1bc40d1a9f6777689026804a1ccfc340b82680d3ec5304ddd68f78c`. + +Оба CLKOUT выключены без ошибок. Повторная проверка процессов получила CLEAR. +Команд передачи не было. + +```text +LAB065 PREPARATION = PASS +COMMON CLOCK = PASS +RX PREFLIGHT = PASS +RF TX = 0 +READY FOR SINGLE LAB065 TX = YES +WAITING FOR EXPLICIT RF AUTHORIZATION = YES +``` + +Готовность означает, что программные и аппаратные входные проверки пройдены. +Она не разрешает передачу и не предсказывает результат измерения. diff --git a/experiments/lab065_rf_level_linearity.py b/experiments/lab065_rf_level_linearity.py new file mode 100644 index 0000000..62e03a7 --- /dev/null +++ b/experiments/lab065_rf_level_linearity.py @@ -0,0 +1,623 @@ +"""Lab065: та же лестница уровней при TX VGA 20 дБ. + +Подготовка, программная проверка и preflight не передают RF. Единственный +аппаратный сеанс доступен только отдельной командой с явным разрешением. +""" + +from __future__ import annotations + +from dataclasses import asdict +import argparse +import hashlib +import json +from pathlib import Path +import shutil +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 experiments import lab054_hardware_control as lab054 +from experiments import lab056_capture_runner as hardware +from experiments import lab064_rf_level_linearity as lab064 + + +ROOT = Path(__file__).resolve().parents[1] +RAW = ROOT / "data/raw/lab065" +OUTPUT = ROOT / "data/processed/lab065" +LAB064_RAW = ROOT / "data/raw/lab064" +LAB064_RESULT = ROOT / "data/processed/lab064/rf_result.json" +EXPECTED_TX_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9" +TX_VGA_DB = 20 +TX_VGA_DIFFERENCE_DB = 10.0 +MEANINGFUL_GAIN_DB = 3.0 +MEANINGFUL_RATIO_IMPROVEMENT_DB = 3.0 +MAX_LEVEL_TO_LEVEL_GAIN_SPREAD_DB = 3.0 +RESIDUAL_TRACKING_MARGIN_DB = 3.0 + +POINT = hardware.OperatingPoint(**asdict(lab064.POINT)) + + +def save_new(path: Path, value: dict) -> None: + """Записать новое свидетельство и не перезаписывать результат опыта.""" + 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: + result = lab064.parameters() + result["tx_vga_db"] = TX_VGA_DB + result["geometry_unchanged_from_lab064"] = True + result["waveform_source"] = "Lab064 exact CS8 copy" + return result + + +def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]: + command = lab064.transfer_command(path, samples, transmit=transmit) + if transmit: + command[command.index("-x") + 1] = str(TX_VGA_DB) + return command + + +def validate_lab064_waveform() -> dict: + source = LAB064_RAW / "tx_level_staircase.cs8" + metadata = json.loads((LAB064_RAW / "tx_metadata.json").read_text(encoding="utf-8")) + digest = sha256(source) + if ( + digest != EXPECTED_TX_SHA256 + or metadata.get("tx_sha256") != EXPECTED_TX_SHA256 + or source.stat().st_size != 2 * lab064.TX_SAMPLES + or metadata.get("segments") != lab064.prepared_segments()[0] + ): + raise RuntimeError("Waveform Lab064 не совпадает с зафиксированным образцом") + return metadata + + +def validate_prepared() -> dict: + metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8")) + path = RAW / "tx_level_staircase.cs8" + if ( + metadata.get("lab") != "Lab065" + or metadata.get("stage") != "PREPARATION_ONLY" + or metadata.get("rf_tx_count") != 0 + or metadata.get("parameters") != parameters() + or metadata.get("tx_sha256") != EXPECTED_TX_SHA256 + or metadata.get("source_tx_sha256") != EXPECTED_TX_SHA256 + or path.stat().st_size != 2 * lab064.TX_SAMPLES + or sha256(path) != EXPECTED_TX_SHA256 + ): + raise RuntimeError("Подготовленный Lab065 TX или параметры изменились") + return metadata + + +def prepare() -> dict: + """Скопировать точные байты Lab064 в отдельный каталог Lab065.""" + source_metadata = validate_lab064_waveform() + RAW.mkdir(parents=True, exist_ok=True) + OUTPUT.mkdir(parents=True, exist_ok=True) + target = RAW / "tx_level_staircase.cs8" + if target.exists(): + raise FileExistsError("TX-файл Lab065 уже существует") + shutil.copyfile(LAB064_RAW / "tx_level_staircase.cs8", target) + if sha256(target) != EXPECTED_TX_SHA256: + target.unlink() + raise RuntimeError("Копия waveform Lab065 не прошла SHA-256") + result = { + "lab": "Lab065", + "stage": "PREPARATION_ONLY", + "rf_tx_count": 0, + "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "parameters": parameters(), + "segments": source_metadata["segments"], + "tx_samples": lab064.TX_SAMPLES, + "tx_bytes": 2 * lab064.TX_SAMPLES, + "tx_duration_seconds": lab064.TX_SAMPLES / lab064.SAMPLE_RATE, + "tx_sha256": sha256(target), + "source_tx_sha256": source_metadata["tx_sha256"], + "waveform_unchanged_from_lab064": True, + "automatic_retry": False, + "tx_command_prepared_not_run": transfer_command(target, lab064.TX_SAMPLES, transmit=True), + "rx_command_prepared_not_run": transfer_command(RAW / "rx_stream.cs8", lab064.RX_SAMPLES), + } + save_new(RAW / "tx_metadata.json", result) + save_new(OUTPUT / "preparation.json", result) + return result + + +def compare_sessions(old_result: dict, new_rows: list[dict]) -> dict: + """Сопоставить одинаковые цифровые уровни Lab064 и Lab065.""" + old_by_label = {row["label"]: row for row in old_result["rows"]} + rows = [] + for new in new_rows: + old = old_by_label[new["label"]] + comparison = { + "label": new["label"], + "tx_vga_nominal_difference_db": TX_VGA_DIFFERENCE_DB, + "rx_matched_power_difference_db": lab064.difference_db( + new["matched_signal_power"], old["matched_signal_power"] + ), + "gain_power_abs_h_squared_difference_db": lab064.difference_db( + new["gain_power_abs_h_squared"], old["gain_power_abs_h_squared"] + ), + "residual_power_difference_db": lab064.difference_db( + new["residual_power"], old["residual_power"] + ), + "matched_to_residual_improvement_db": ( + new["matched_to_residual_db"] - old["matched_to_residual_db"] + ), + } + if "complex_gain_h" in new and "residual_spectrum" in new: + old_gain = complex( + old["complex_gain_h"]["real"], old["complex_gain_h"]["imag"] + ) + new_gain = complex( + new["complex_gain_h"]["real"], new["complex_gain_h"]["imag"] + ) + comparison["complex_gain_h_old"] = old["complex_gain_h"] + comparison["complex_gain_h_new"] = new["complex_gain_h"] + comparison["complex_gain_phase_change_degrees"] = float( + np.degrees(np.angle(new_gain / old_gain)) + ) + comparison["residual_spectral_band_difference_db"] = { + f"h{harmonic}_{harmonic * lab064.CARRIER_HZ}_hz": lab064.difference_db( + new["residual_spectrum"][f"residual_band_h{harmonic}_power_code2"], + old["residual_spectrum"][f"residual_band_h{harmonic}_power_code2"], + ) + for harmonic in (1, 2, 3) + } + rows.append(comparison) + return {"rows": rows, "rules_fixed_before_rf": classification_rules()} + + +def classification_rules() -> dict: + return { + "internal_linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "meaningful_matched_gain_median_at_least_db": MEANINGFUL_GAIN_DB, + "meaningful_ratio_improvement_median_at_least_db": MEANINGFUL_RATIO_IMPROVEMENT_DB, + "maximum_level_to_level_gain_spread_db": MAX_LEVEL_TO_LEVEL_GAIN_SPREAD_DB, + "residual_tracks_if_within_matched_gain_db": RESIDUAL_TRACKING_MARGIN_DB, + } + + +def classify_result(new_result: dict, cross_comparison: dict) -> dict: + """Выбрать один из пяти заранее заданных результатов Lab065.""" + if not new_result.get("capture_valid"): + return {"case": "INVALID_CAPTURE", "rules_fixed_before_rf": classification_rules()} + internal_errors = [row["linearity_error_db"] for row in new_result["comparisons"]] + upper_error = internal_errors[-1] + cross_rows = cross_comparison["rows"] + matched = np.asarray([row["rx_matched_power_difference_db"] for row in cross_rows]) + residual = np.asarray([row["residual_power_difference_db"] for row in cross_rows]) + improvements = np.asarray([row["matched_to_residual_improvement_db"] for row in cross_rows]) + median_matched = float(np.median(matched)) + median_residual = float(np.median(residual)) + median_improvement = float(np.median(improvements)) + spread = float(np.ptp(matched)) + linear = max(abs(value) for value in internal_errors) <= 1.0 + if upper_error <= -1.5: + case = "COMPRESSION_AT_HIGHER_TX_GAIN" + elif median_matched < MEANINGFUL_GAIN_DB: + case = "NO_MEANINGFUL_GAIN" + elif ( + linear + and spread <= MAX_LEVEL_TO_LEVEL_GAIN_SPREAD_DB + and median_improvement >= MEANINGFUL_RATIO_IMPROVEMENT_DB + ): + case = "LINEAR_GAIN_IMPROVEMENT" + elif ( + median_residual >= median_matched - RESIDUAL_TRACKING_MARGIN_DB + and median_improvement < MEANINGFUL_RATIO_IMPROVEMENT_DB + ): + case = "RESIDUAL_RISES_WITH_SIGNAL" + else: + case = "NOT_LOCALIZED" + return { + "case": case, + "summary": { + "median_matched_gain_db": median_matched, + "median_residual_gain_db": median_residual, + "median_matched_to_residual_improvement_db": median_improvement, + "matched_gain_level_spread_db": spread, + "maximum_internal_linearity_error_db": max(abs(value) for value in internal_errors), + "upper_step_linearity_error_db": upper_error, + }, + "rules_fixed_before_rf": classification_rules(), + } + + +def software_gate() -> dict: + metadata = validate_prepared() + old_result = json.loads(LAB064_RESULT.read_text(encoding="utf-8")) + synthetic = lab064.synthetic_linearity_gate() + result = { + "lab": "Lab065", + "stage": "SOFTWARE_GATE", + "rf_tx_count": 0, + "passed": bool(synthetic["passed"] and old_result["capture_valid"]), + "tx_sha256": metadata["tx_sha256"], + "waveform_unchanged_from_lab064": metadata["waveform_unchanged_from_lab064"], + "lab064_baseline_classification": old_result["classification"]["case"], + "classification_rules": classification_rules(), + } + save_new(OUTPUT / "software_gate.json", result) + if not result["passed"]: + raise RuntimeError("Lab065 software gate FAIL") + return result + + +def preflight(*, runner=None, process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict: + """Проверить общую опору и 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("Нет действующего Lab065 software gate") + save_new(RAW / "preflight_attempt.json", { + "lab": "Lab065", "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 запрещён в Lab065 preflight") + return actual_runner(command) + + result = { + "lab": "Lab065", "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" + captured = rx_only(transfer_command(path, lab064.PREFLIGHT_SAMPLES)) + stdout, stderr = captured.stdout or "", captured.stderr or "" + result["rx_process"] = { + "returncode": captured.returncode, "stdout": stdout, "stderr": stderr, + } + result["rx_counter_summary"] = lab064.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 * lab064.PREFLIGHT_SAMPLES: + raise RuntimeError("Неверная длина Lab065 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"] >= lab064.STRONG_AC_RMS_CODE + or level["max_abs_i"] >= lab064.STRONG_COMPONENT_CODE + or level["max_abs_q"] >= lab064.STRONG_COMPONENT_CODE + ) + result["rx_level"] = level + result["rx_sha256"] = sha256(path) + result["rx_overruns"] = result["rx_counter_summary"]["final_count"] + if level["saturation_detected"] or level["unexpected_strong_signal"]: + raise RuntimeError("RX-only уровень не прошёл gate; 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("Lab065 preflight 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 для конфигурации Lab065") + 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 сеанс после отдельного прямого разрешения.""" + if authorization is not True: + raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab065") + metadata = validate_prepared() + require_preflight(metadata) + receive_path = RAW / "rx_stream.cs8" + if receive_path.exists() or (RAW / "capture_result.json").exists(): + raise FileExistsError("Аппаратный результат Lab065 уже существует") + 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": "Lab065", "authorized_once": True, "automatic_retry": False, + "point": asdict(POINT), "parameters": parameters(), + "tx_sha256": metadata["tx_sha256"], + }) + result = { + "lab": "Lab065", "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, lab064.RX_SAMPLES), + RAW / "rx_stdout.txt", RAW / "rx_stderr.txt", + ) + sleep_function(lab064.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", lab064.TX_SAMPLES, transmit=True) + ) + tx_stdout, tx_stderr = transmitted.stdout or "", 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"] = lab064.counter_summary( + tx_stdout + tx_stderr, "underrun" + ) + if transmitted.returncode: + raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён") + if process.wait(lab064.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"] / lab064.SAMPLE_RATE + if result["rx_bytes"] != 2 * lab064.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"] = lab064.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"]) + result["passed"] = True + validity = lab064.capture_validity(result) + if not validity["valid"]: + result["passed"] = False + raise RuntimeError("Запись невалидна; результат сохранён, повтор запрещён") + 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("Lab065 RF-сеанс завершён с ошибкой; повтор запрещён") + return result + + +def analyze_capture() -> dict: + """Рассчитать метрики Lab065 и прямое сравнение с Lab064.""" + 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-записи Lab065") + tx = lab064.energy.Cs8File(RAW / "tx_level_staircase.cs8") + rx = lab064.energy.Cs8File(rx_path) + location = lab064.locate_waveform(tx, rx) + background_rows = [] + for window in lab064.background_windows(): + rx_start = location["start_sample"] + window["tx_relative_start_sample"] + background_rows.append({ + **window, "rx_start_sample": rx_start, + "metrics": lab064._window_metrics(rx.read(rx_start, window["samples"])), + }) + background_by_label = {row["label"]: row for row in background_rows} + preceding = {"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 = lab064.energy.analyze_rx( + lab064.lab056.OffsetSource(tx, tx_start), rx, [nominal_rx_start], + lab064.SEGMENT_SAMPLES, lab064.SAMPLES_PER_SYMBOL, + )["frames"][0] + actual_start = fitted["start"] + active_reference = tx.read( + tx_start + lab064.TRIM_SAMPLES, + lab064.SEGMENT_SAMPLES - 2 * lab064.TRIM_SAMPLES, + ) + active_received = rx.read( + actual_start + lab064.TRIM_SAMPLES, + lab064.SEGMENT_SAMPLES - 2 * lab064.TRIM_SAMPLES, + ) + complex_gain = lab064.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[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": lab064._window_metrics(rx.read(actual_start, lab064.SEGMENT_SAMPLES)), + "background_label": background["label"], "background": background["metrics"], + "matched_signal_power": matched, "residual_power": residual, + "matched_to_residual_db": lab064.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": lab064.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 = lab064.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, + }) + validity = lab064.capture_validity(capture) + result = { + "lab": "Lab065", "stage": "OFFLINE_RF_LEVEL_LINEARITY_ANALYSIS", + "packet_receiver_used": False, "location": location, "rows": rows, + "background_windows": background_rows, + "background_summary": lab064.summarize_background(background_rows), + "comparisons": comparisons, "capture_valid": validity["valid"], + "capture_invalid_reasons": validity["invalid_reasons"], + "tx_underruns_maximum": capture.get("tx_counter_summary", {}).get("maximum_count"), + "rx_overruns_maximum": capture.get("rx_counter_summary", {}).get("maximum_count"), + "rx_clipping": any(row["full_iq"]["clipped_samples_fraction"] > 0 for row in rows), + } + old_result = json.loads(LAB064_RESULT.read_text(encoding="utf-8")) + result["lab064_to_lab065"] = compare_sessions(old_result, rows) + result["classification"] = classify_result(result, result["lab064_to_lab065"]) + save_new(OUTPUT / "rf_result.json", result) + return result + + +def run_functional_tests() -> None: + assert transfer_command("NUL", 100, transmit=True)[-7:-5] == ["-x", "20"] + assert "-t" not in transfer_command("NUL", 100) + assert validate_lab064_waveform()["tx_sha256"] == EXPECTED_TX_SHA256 + assert lab064.synthetic_linearity_gate()["passed"] + 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 NOW = 0") + + +if __name__ == "__main__": + main() diff --git a/tests/test_lab065_rf_level_linearity.py b/tests/test_lab065_rf_level_linearity.py new file mode 100644 index 0000000..1ad0081 --- /dev/null +++ b/tests/test_lab065_rf_level_linearity.py @@ -0,0 +1,151 @@ +"""Lab065: неизменный waveform, TX VGA 20 дБ и границы классификации.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from experiments import lab065_rf_level_linearity as lab +from tools.hackrf_process_check import ProcessCheck, ProcessState + + +def test_only_operating_parameter_change_is_tx_vga(): + old = lab.lab064.parameters() + new = lab.parameters() + assert new.pop("geometry_unchanged_from_lab064") is True + assert new.pop("waveform_source") == "Lab064 exact CS8 copy" + assert new["tx_vga_db"] == 20 + new["tx_vga_db"] = 10 + assert new == old + + +def test_tx_command_uses_20_db_and_rx_remains_24_24(): + transmit = lab.transfer_command("tx.cs8", 100, transmit=True) + receive = lab.transfer_command("rx.cs8", 100) + assert transmit[transmit.index("-x") + 1] == "20" + assert receive[receive.index("-l") + 1] == "24" + assert receive[receive.index("-g") + 1] == "24" + assert transmit[-5:] == receive[-5:] == ["-a", "0", "-p", "0", "-B"] + + +def test_exact_lab064_waveform_is_required(): + metadata = lab.validate_lab064_waveform() + assert metadata["tx_sha256"] == lab.EXPECTED_TX_SHA256 + assert metadata["tx_samples"] == 6_000_000 + + +def _result(matched_gain, residual_gain, improvements, errors=(0.0, 0.0, 0.0)): + old = {row["label"]: row for row in json.loads( + lab.LAB064_RESULT.read_text(encoding="utf-8") + )["rows"]} + rows = [] + for index, label in enumerate(("L0", "L1", "L2", "L3")): + baseline = old[label] + rows.append({ + "label": label, + "matched_signal_power": baseline["matched_signal_power"] * 10 ** (matched_gain[index] / 10), + "residual_power": baseline["residual_power"] * 10 ** (residual_gain[index] / 10), + "matched_to_residual_db": baseline["matched_to_residual_db"] + improvements[index], + "gain_power_abs_h_squared": baseline["gain_power_abs_h_squared"] * 10 ** (matched_gain[index] / 10), + }) + return { + "capture_valid": True, + "rows": rows, + "comparisons": [{"linearity_error_db": error} for error in errors], + } + + +@pytest.mark.parametrize( + ("result", "expected"), + [ + (_result([10] * 4, [1] * 4, [9] * 4), "LINEAR_GAIN_IMPROVEMENT"), + (_result([10] * 4, [10] * 4, [0] * 4), "RESIDUAL_RISES_WITH_SIGNAL"), + (_result([1] * 4, [0] * 4, [1] * 4), "NO_MEANINGFUL_GAIN"), + (_result([10] * 4, [1] * 4, [9] * 4, errors=(0, 0, -1.6)), "COMPRESSION_AT_HIGHER_TX_GAIN"), + ], +) +def test_classification_cases(result, expected): + comparison = lab.compare_sessions( + json.loads(lab.LAB064_RESULT.read_text(encoding="utf-8")), result["rows"] + ) + assert lab.classify_result(result, comparison)["case"] == expected + + +@pytest.mark.parametrize("failure", [None, "clock", "overrun", "clipping"]) +def test_preflight_never_transmits_and_fails_closed(tmp_path, monkeypatch, failure): + raw, output = tmp_path / "raw", tmp_path / "output" + raw.mkdir() + output.mkdir() + monkeypatch.setattr(lab, "RAW", raw) + monkeypatch.setattr(lab, "OUTPUT", output) + (raw / "tx_level_staircase.cs8").write_bytes(b"test") + metadata = {"tx_sha256": "TEST"} + (output / "software_gate.json").write_text('{"passed": true, "tx_sha256": "TEST"}') + monkeypatch.setattr(lab, "validate_prepared", lambda: metadata) + monkeypatch.setattr(lab.lab054.phy.lab045, "_verify_device_inventory", lambda runner: {"passed": True}) + monkeypatch.setattr(lab.lab054, "disable_clock_outputs", lambda runner: []) + monkeypatch.setattr(lab, "parameters", lambda: {}) + + def clock(point, runner, guard, **kwargs): + return {"passed": failure != "clock", "master_reg29": 0x26, + "slave_reg29": 0x26, "slave_clkin_status": "DETECTED"} + + def runner(command): + assert "-t" not in command + if "-r" in command: + value = 127 if failure == "clipping" else 1 + Path(command[command.index("-r") + 1]).write_bytes(bytes([value, value]) * 800_000) + log = "1 RX overruns" if failure == "overrun" else "0 RX overruns" + return subprocess.CompletedProcess(command, 0, log, "") + + options = { + "runner": runner, + "clock_checker": clock, + "process_checker": lambda: ProcessCheck(ProcessState.CLEAR, ()), + "sleep_function": lambda _: None, + } + if failure: + with pytest.raises(RuntimeError): + lab.preflight(**options) + else: + assert lab.preflight(**options)["passed"] + assert json.loads((raw / "preflight_result.json").read_text())["rf_tx_count"] == 0 + + +def test_capture_is_unavailable_without_new_authorization(): + with pytest.raises(PermissionError): + lab.capture_once(authorization=False) + + +def test_existing_attempt_blocks_capture_before_hardware(tmp_path, monkeypatch): + monkeypatch.setattr(lab, "RAW", tmp_path) + monkeypatch.setattr(lab, "validate_prepared", lambda: {"tx_sha256": "TEST"}) + monkeypatch.setattr(lab, "require_preflight", lambda metadata: {}) + (tmp_path / "hardware_attempt.json").write_text("{}", encoding="utf-8") + with pytest.raises(FileExistsError): + lab.capture_once( + authorization=True, + runner=lambda command: pytest.fail("hardware must not run"), + process_starter=lambda *args: pytest.fail("hardware must not run"), + ) + + +def test_cli_labels_current_rf_state_after_completed_capture(monkeypatch, capsys): + monkeypatch.setattr( + lab, + "capture_once", + lambda authorization=False: { + "stage": "SINGLE_RF_LEVEL_CAPTURE", "rf_tx_count": 1, "passed": True, + }, + ) + monkeypatch.setattr( + sys, "argv", ["lab065", "--stage", "capture", "--authorize-one-rf"] + ) + lab.main() + output = capsys.readouterr().out + assert '"rf_tx_count": 1' in output + assert output.endswith("RF TX NOW = 0\n")