diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index e83b742..6359f84 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -2780,3 +2780,84 @@ DC стабилен. Сильная аналоговая перегрузка п выполнялся. Следующий этап: STOP и отдельно согласовать исследование уровня RX, усилений, геометрии и возможной аналоговой компрессии или слабого полезного сигнала; Lab064 автоматически не создавать. + +# Запись 059 + +## Дата + +14 сентября 2026 года + +## Тема + +Lab064: подготовка измерения уровня и линейности, RX-only preflight + +## Выполнено + +После фиксации Lab063 коммитом 4437917465274257ff0ca5d3c269029da45de3ec +подготовлен один измерительный PRBS7/BPSK/RRC waveform без полного PHY-пакета. +Параметры: 1400 МГц, 20000 символов/с, Fs 8000000 комплексных отсчётов/с, +цифровая несущая +50000 Гц, TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amplifier и +Bias-Tee выключены. Геометрия сохранена: примерно 1,2 м, LOS в помещении, TX +возле окна, RX на столе, одинаковая поляризация. + +TX-файл содержит 100 мс начальных нулей, четыре одинаковых 100-мс участка с +цифровыми уровнями −18/−12/−6/0 дБ, три нулевых промежутка по 50 мс и 100 мс +конечных нулей. Полная длина равна 6000000 отсчётов и 0,75 с. RMS участков: +5,556137/11,056978/22,056892/44,000457 кода; фактические шаги мощности: ++5,977270/+5,998157/+5,998257 дБ. Максимумы |I|/|Q| не превышают 69/69, +цифрового clipping нет. SHA-256 TX: +fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9. + +Подготовлены RX-first runner и офлайн-анализатор согласованной составляющей, +остатка, комплексного h и линейности. Полные stdout/stderr и все промежуточные +и финальные счётчики потерь сохраняются. Независимая read-only проверка нашла +и помогла закрыть fail-closed ошибку: промежуточный underrun с финальным нулём +теперь всегда даёт `INVALID_CAPTURE`, диагностические метрики не превращаются +в A/B/C. Целевые тесты:13 passed. Quick gate:984 теста ядра,37 функциональных +проверок,13 пропусков; pytest:984 passed,0 failed; diff-check PASS. + +Один разрешённый RX-only preflight подтвердил MASTER/SLAVE reg29=0x26 и SLAVE +CLKIN=DETECTED при первом опросе через66,793мс. Записано800000 отсчётов фона: +полный RMS2,193193, AC RMS0,832038 кода, средние I/Q 0,933738/1,801650, +max|I|/|Q|=4/5, clipping0, overruns0, неожиданно сильного сигнала нет. SHA-256 +RX-only записи:02d87adcbb621027290a77d5f8fd3190c299d13e89f887cd294d4af352a6ef52. +CLKOUT выключены без ошибок, процессы до и после CLEAR. + +Вызовов TX не было, `RF TX=0`. Lab064 не коммитируется до аппаратного +результата, push не выполняется. Следующий этап: STOP и ожидание отдельного +явного разрешения на единственный Lab064 TX; Lab065 не начинать. + +После отдельного прямого разрешения пользователя выполнен ровно один RF-сеанс +подготовленного Lab064 waveform. Перед TX MASTER/SLAVE reg29 равнялись0x26, +SLAVE CLKIN был обнаружен при втором опросе через139,042мс. RX стартовал раньше +TX. Автоматических и ручных повторов не было. Все две строки TX underrun и все +четыре строки RX overrun равны нулю. RX clipping отсутствует. Записано16000000 +комплексных отсчётов за2,000000с, SHA-256 +02015891948aaee52ff81e60fdfeb391bb9b76cd9192b75972b8fedbc8690d86. +После отключения CLKOUT процессы CLEAR, ошибок очистки нет, RF TX=0. + +Пять окон цифрового нуля дали AC RMS0,718264…0,728217 кода; разброс фоновой +AC-мощности равен0,119530дБ, фон стабилен. Согласованная мощность L0/L1/L2/L3 +равна0,000750751/0,002961235/0,011333436/0,045331864код². Ошибки трёх ступеней +относительно цифрового TX равны−0,017501/−0,169270/+0,022162дБ. Общий рост +L3/L0 равен17,809077дБ при цифровых17,973685дБ; ошибка−0,164609дБ. |h| +составляет0,004882853/0,004872939/0,004778930/0,004791153. Верхняя ступень не +показывает компрессии. + +Matched/residual для L0…L3 равен−32,086493/−25,577463/−16,691669/−12,133336дБ. +По заранее заданным критериям результат `LINEAR_BUT_WEAK`: тракт линейно +следует цифровому уровню, но полезная согласованная составляющая даже на L3 +слабее остатка. Абсолютная RF-мощность и разделение вклада TX/эфира/RX без +измерительного прибора не установлены. + +Quick gate после сеанса прошёл:986 тестов ядра,37 функциональных проверок,13 +пропусков. Полный pytest:986 passed,0 failed. Lab064 не коммитировалась, push +не выполнялся. Следующий отдельный шаг рекомендуется только после согласования: +повтор той же лестницы при TX VGA20дБ и остальных неизменных условиях. Lab065 +не создана. + +Независимая read-only проверка повторно вычислила SHA, положение сегментов, +пять окон фона, matched/residual, комплексные h, ступени мощности и амплитуды, +полное сравнение L3/L0 и двенадцать спектральных значений непосредственно из +CS8. Расхождений нет, вердикт `чисто`. Оборудование при проверке не опрашивалось, +RF TX остаётся равным нулю. diff --git a/data/processed/lab064/capture_result.json b/data/processed/lab064/capture_result.json new file mode 100644 index 0000000..66a2586 --- /dev/null +++ b/data/processed/lab064/capture_result.json @@ -0,0 +1,230 @@ +{ + "lab": "Lab064", + "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": 10, + "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 + ] + }, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "process_before": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 333, + "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": 139.0420999377966, + "elapsed_ms": 139.0420999377966, + "polls": [ + { + "query": 1, + "start_ms": 50.13589980080724, + "end_ms": 67.04779993742704, + "raw": "CLKIN status: no clock signal detected", + "state": "NOT_DETECTED", + "exit_code": 0 + }, + { + "query": 2, + "start_ms": 117.27159982547164, + "end_ms": 139.0420999377966, + "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.9547576904296875, + "mean_q": 1.83575439453125, + "power_code2": 4.8329315185546875, + "ac_power_code2": 0.5513750740792602, + "rms_code": 2.1983929399801774, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.710618637663133, + "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": 336 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 458 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 580 + }, + { + "count": 0, + "role": "UNKNOWN", + "kind": "overrun", + "text": "0 overruns", + "character_offset": 751 + } + ], + "selected_counts": [ + 0, + 0, + 0, + 0 + ], + "final_count": 0, + "maximum_count": 0, + "unparsed_loss_text": false, + "clean": true + }, + "rx_sha256": "02015891948aaee52ff81e60fdfeb391bb9b76cd9192b75972b8fedbc8690d86", + "rx_level": { + "samples": 16000000, + "mean_i": 0.9548163125, + "mean_q": 1.83674125, + "power_code2": 5.2568038125, + "ac_power_code2": 0.9715112024323407, + "rms_code": 2.2927720803647276, + "peak_magnitude_code": 8.602325267042627, + "max_abs_i": 7.0, + "max_abs_q": 8.0, + "papr_db": 11.485099505760791, + "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": 333, + "errors": [] + } + ] + }, + "cleanup_errors": [] +} diff --git a/data/processed/lab064/preflight_result.json b/data/processed/lab064/preflight_result.json new file mode 100644 index 0000000..73a3ba3 --- /dev/null +++ b/data/processed/lab064/preflight_result.json @@ -0,0 +1,164 @@ +{ + "lab": "Lab064", + "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": 10, + "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 + ] + }, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "process_before": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 329, + "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": 1, + "time_to_detect_ms": 66.79299985989928, + "elapsed_ms": 66.79299985989928, + "polls": [ + { + "query": 1, + "start_ms": 50.07890006527305, + "end_ms": 66.79299985989928, + "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.117 sec = 15.6 MiB/second, average power -35.3 dBfs, 9792 bytes free in buffer, 0 overruns, longest 0 bytes\n\nExiting...\nTotal time: 0.11842 s\nhackrf_stop_rx() done\nTransfer statistics:\n1892448 bytes transferred by M0\n1867776 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.9337375, + "mean_q": 1.80165, + "power_code2": 4.810095, + "ac_power_code2": 0.6922865585937501, + "rms_code": 2.1931928779749397, + "peak_magnitude_code": 5.385164807134504, + "max_abs_i": 4.0, + "max_abs_q": 5.0, + "papr_db": 7.802443440680863, + "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": 0.8320375944593791, + "unexpected_strong_signal": false, + "strong_signal_rule": "AC RMS >= 20.0 code or max |I|/|Q| >= 100.0 code" + }, + "rx_sha256": "02d87adcbb621027290a77d5f8fd3190c299d13e89f887cd294d4af352a6ef52", + "rx_overruns": 0, + "process_after": { + "state": "CLEAR", + "attempts": [ + { + "state": "CLEAR", + "mechanism": "psutil", + "matches": [], + "inspected": 329, + "errors": [] + } + ] + }, + "cleanup_errors": [] +} diff --git a/data/processed/lab064/preparation.json b/data/processed/lab064/preparation.json new file mode 100644 index 0000000..e30cf24 --- /dev/null +++ b/data/processed/lab064/preparation.json @@ -0,0 +1,221 @@ +{ + "lab": "Lab064", + "stage": "PREPARATION_ONLY", + "rf_tx_count": 0, + "head": "4437917465274257ff0ca5d3c269029da45de3ec", + "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": 10, + "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 + ] + }, + "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 + } + ], + "leading_zero_samples": 800000, + "gap_samples": 400000, + "trailing_zero_samples": 800000, + "tx_samples": 6000000, + "tx_bytes": 12000000, + "tx_duration_seconds": 0.75, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9", + "rx_samples": 16000000, + "rx_duration_seconds": 2.0, + "rx_lead_seconds": 0.5, + "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\\lab064\\tx_level_staircase.cs8", + "-f", + "1400000000", + "-s", + "8000000", + "-n", + "6000000", + "-x", + "10", + "-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\\lab064\\rx_stream.cs8", + "-f", + "1400000000", + "-s", + "8000000", + "-n", + "16000000", + "-l", + "24", + "-g", + "24", + "-a", + "0", + "-p", + "0", + "-B" + ] +} diff --git a/data/processed/lab064/rf_result.json b/data/processed/lab064/rf_result.json new file mode 100644 index 0000000..a46c778 --- /dev/null +++ b/data/processed/lab064/rf_result.json @@ -0,0 +1,425 @@ +{ + "lab": "Lab064", + "stage": "OFFLINE_RF_LEVEL_LINEARITY_ANALYSIS", + "packet_receiver_used": false, + "location": { + "start_sample": 4123520, + "correlation": 0.0888256100450642, + "coarse_cfo_hz": 0.0, + "stride": 32 + }, + "rows": [ + { + "label": "L0", + "level_db": -18.0, + "rx_start_sample": 4923521, + "alignment_adjustment_samples": 1, + "full_iq": { + "samples": 800000, + "mean_i": 0.94972375, + "mean_q": 1.8355175, + "power_code2": 5.18564125, + "ac_power_code2": 0.9145415558796861, + "rms_code": 2.2772003095907043, + "peak_magnitude_code": 7.0710678118654755, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 9.841675359702911, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00025928206249999997, + "ac_rms_code": 0.9563166608815754 + }, + "background_label": "INITIAL_GUARD", + "background": { + "samples": 160000, + "mean_i": 0.940225, + "mean_q": 1.8409875, + "power_code2": 4.7947875, + "ac_power_code2": 0.5215294742187488, + "rms_code": 2.189700321961889, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.7450314065030845, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000239739375, + "ac_rms_code": 0.7221699759881665 + }, + "background_window_start_sample": 4443520, + "matched_signal_power": 0.0007507510158784873, + "matched_signal_amplitude_rms": 0.027399836055686307, + "residual_power": 1.2137948040249413, + "residual_to_matched_amplitude_ratio": 40.209129659782626, + "matched_to_residual_db": -32.08649345507262, + "complex_gain_h": { + "real": 0.0018760642981310563, + "imag": -0.0045080629571906915, + "phase_rad_at_active_start": -1.1764391150184978 + }, + "gain_abs": 0.00488285253481168, + "gain_power_abs_h_squared": 2.384224887671685e-05, + "cfo_hz": 0.0957717781274782, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.0029441230361582733, + "residual_band_h2_power_code2": 0.002517548539085255, + "residual_band_h3_power_code2": 0.0017878979213816356, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L1", + "level_db": -12.0, + "rx_start_sample": 6123541, + "alignment_adjustment_samples": 21, + "full_iq": { + "samples": 800000, + "mean_i": 0.9560375, + "mean_q": 1.8325725, + "power_code2": 5.2504775, + "ac_power_code2": 0.9781478308375009, + "rms_code": 2.2913920441513276, + "peak_magnitude_code": 7.211102550927978, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 9.958045419078847, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000262523875, + "ac_rms_code": 0.9890135645366553 + }, + "background_label": "GAP_L0_L1", + "background": { + "samples": 160000, + "mean_i": 0.95144375, + "mean_q": 1.8329875, + "power_code2": 4.78826875, + "ac_power_code2": 0.5231803654296865, + "rms_code": 2.1882113129220406, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.7509398714368505, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00023941343750000002, + "ac_rms_code": 0.7233120802459243 + }, + "background_window_start_sample": 5883520, + "matched_signal_power": 0.0029612351609719875, + "matched_signal_amplitude_rms": 0.054417232206094304, + "residual_power": 1.0695946032022712, + "residual_to_matched_amplitude_ratio": 19.005231005856622, + "matched_to_residual_db": -25.577463055166668, + "complex_gain_h": { + "real": 0.004471119012843702, + "imag": -0.001937685853593986, + "phase_rad_at_active_start": -0.40894566669212534 + }, + "gain_abs": 0.0048729387123409105, + "gain_power_abs_h_squared": 2.374553169423069e-05, + "cfo_hz": -0.16857111258676097, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.0037442904303572424, + "residual_band_h2_power_code2": 0.0029946268848091966, + "residual_band_h3_power_code2": 0.0022268690579244723, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L2", + "level_db": -6.0, + "rx_start_sample": 7323533, + "alignment_adjustment_samples": 13, + "full_iq": { + "samples": 800000, + "mean_i": 0.9631825, + "mean_q": 1.84173875, + "power_code2": 5.17589875, + "ac_power_code2": 0.856176598442187, + "rms_code": 2.275060164039624, + "peak_magnitude_code": 7.280109889280518, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 10.102900974475599, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002587949375, + "ac_rms_code": 0.925298113281437 + }, + "background_label": "GAP_L1_L2", + "background": { + "samples": 160000, + "mean_i": 0.9560375, + "mean_q": 1.8575625, + "power_code2": 4.88045, + "ac_power_code2": 0.5159038571874985, + "rms_code": 2.209174053803819, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.668126373000443, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002440225, + "ac_rms_code": 0.7182644757939087 + }, + "background_window_start_sample": 7083520, + "matched_signal_power": 0.011333436190702588, + "matched_signal_amplitude_rms": 0.10645861257175292, + "residual_power": 0.5290886880136152, + "residual_to_matched_amplitude_ratio": 6.832559719073439, + "matched_to_residual_db": -16.691668725481204, + "complex_gain_h": { + "real": 0.0032772531007889995, + "imag": -0.003478187284280655, + "phase_rad_at_active_start": -0.8151334504253348 + }, + "gain_abs": 0.00477893028523777, + "gain_power_abs_h_squared": 2.283817467116275e-05, + "cfo_hz": 0.006742170898886185, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.004466260208758569, + "residual_band_h2_power_code2": 0.0030543665029441423, + "residual_band_h3_power_code2": 0.0020345488796523733, + "interpretation": "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + } + }, + { + "label": "L3", + "level_db": 0.0, + "rx_start_sample": 8523534, + "alignment_adjustment_samples": 14, + "full_iq": { + "samples": 800000, + "mean_i": 0.9575575, + "mean_q": 1.8561875, + "power_code2": 5.48103, + "ac_power_code2": 1.1186815990375, + "rms_code": 2.3411599689043037, + "peak_magnitude_code": 7.280109889280518, + "max_abs_i": 6.0, + "max_abs_q": 7.0, + "papr_db": 9.85413690444334, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002740515, + "ac_rms_code": 1.0576774551050523 + }, + "background_label": "GAP_L2_L3", + "background": { + "samples": 160000, + "mean_i": 0.97165, + "mean_q": 1.8424125, + "power_code2": 4.8607875, + "ac_power_code2": 0.5221999573437506, + "rms_code": 2.204719369897221, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.14323360311441, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00024303937499999998, + "ac_rms_code": 0.7226340410911671 + }, + "background_window_start_sample": 8283520, + "matched_signal_power": 0.0453318643989967, + "matched_signal_amplitude_rms": 0.21291280938214285, + "residual_power": 0.740861773845582, + "residual_to_matched_amplitude_ratio": 4.042656143280289, + "matched_to_residual_db": -12.133336061267785, + "complex_gain_h": { + "real": 0.003384387504328541, + "imag": -0.003391322182689881, + "phase_rad_at_active_start": -0.7864216245934512 + }, + "gain_abs": 0.004791152776343041, + "gain_power_abs_h_squared": 2.295514492625963e-05, + "cfo_hz": -0.07606695286129402, + "residual_spectrum": { + "residual_band_h1_power_code2": 0.009576142863317505, + "residual_band_h2_power_code2": 0.004468597337512337, + "residual_band_h3_power_code2": 0.002725169628283891, + "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": 4443520, + "metrics": { + "samples": 160000, + "mean_i": 0.940225, + "mean_q": 1.8409875, + "power_code2": 4.7947875, + "ac_power_code2": 0.5215294742187488, + "rms_code": 2.189700321961889, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.7450314065030845, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.000239739375, + "ac_rms_code": 0.7221699759881665 + } + }, + { + "label": "GAP_L0_L1", + "tx_relative_start_sample": 1760000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 5883520, + "metrics": { + "samples": 160000, + "mean_i": 0.95144375, + "mean_q": 1.8329875, + "power_code2": 4.78826875, + "ac_power_code2": 0.5231803654296865, + "rms_code": 2.1882113129220406, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.7509398714368505, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00023941343750000002, + "ac_rms_code": 0.7233120802459243 + } + }, + { + "label": "GAP_L1_L2", + "tx_relative_start_sample": 2960000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 7083520, + "metrics": { + "samples": 160000, + "mean_i": 0.9560375, + "mean_q": 1.8575625, + "power_code2": 4.88045, + "ac_power_code2": 0.5159038571874985, + "rms_code": 2.209174053803819, + "peak_magnitude_code": 4.242640687119285, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 5.668126373000443, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002440225, + "ac_rms_code": 0.7182644757939087 + } + }, + { + "label": "GAP_L2_L3", + "tx_relative_start_sample": 4160000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 8283520, + "metrics": { + "samples": 160000, + "mean_i": 0.97165, + "mean_q": 1.8424125, + "power_code2": 4.8607875, + "ac_power_code2": 0.5221999573437506, + "rms_code": 2.204719369897221, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.14323360311441, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.00024303937499999998, + "ac_rms_code": 0.7226340410911671 + } + }, + { + "label": "FINAL_GUARD", + "tx_relative_start_sample": 5440000, + "samples": 160000, + "duration_seconds": 0.02, + "rx_start_sample": 9563520, + "metrics": { + "samples": 160000, + "mean_i": 0.958375, + "mean_q": 1.83606875, + "power_code2": 4.81993125, + "ac_power_code2": 0.5303001546484385, + "rms_code": 2.195434182570728, + "peak_magnitude_code": 4.47213595499958, + "max_abs_i": 3.0, + "max_abs_q": 4.0, + "papr_db": 6.179891520223454, + "clipped_components_fraction": 0.0, + "clipped_samples_fraction": 0.0, + "symbol_energy_code2_seconds": 0.0002409965625, + "ac_rms_code": 0.7282171068084288 + } + } + ], + "background_summary": { + "ac_power_min_code2": 0.5159038571874985, + "ac_power_max_code2": 0.5303001546484385, + "ac_rms_min_code": 0.7182644757939087, + "ac_rms_max_code": 0.7282171068084288, + "maximum_to_minimum_ac_power_db": 0.11952978854087751, + "stability_rule_db": 3.0, + "stable": true + }, + "comparisons": [ + { + "comparison": "L1/L0", + "tx_input_difference_db": 5.9772704691441785, + "rx_matched_power_difference_db": 5.959769689089129, + "linearity_error_db": -0.01750078005504907, + "tx_amplitude_ratio": 1.9900478704552953, + "rx_matched_amplitude_ratio": 1.9860422557090833 + }, + { + "comparison": "L2/L1", + "tx_input_difference_db": 5.998157271536577, + "rx_matched_power_difference_db": 5.828887063637578, + "linearity_error_db": -0.16927020789899938, + "tx_amplitude_ratio": 1.9948390610676463, + "rx_matched_amplitude_ratio": 1.9563400830193338 + }, + { + "comparison": "L3/L2", + "tx_input_difference_db": 5.998257379520232, + "rx_matched_power_difference_db": 6.020419771848732, + "linearity_error_db": 0.022162392328500147, + "tx_amplitude_ratio": 1.9948620524335567, + "rx_matched_amplitude_ratio": 1.9999585213327857 + } + ], + "total_comparison": { + "comparison": "L3/L0", + "tx_digital_power_difference_db": 17.97368512020099, + "rx_matched_power_difference_db": 17.80907652457544, + "total_linearity_error_db": -0.16460859562555186, + "tx_amplitude_ratio": 7.9192536969014835, + "rx_matched_amplitude_ratio": 7.770586982689514 + }, + "classification": { + "case": "LINEAR_BUT_WEAK", + "rules_fixed_before_rf": { + "linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "weak_l3_matched_to_residual_below_db": 3.0, + "healthy_l3_matched_to_residual_at_least_db": 6.0 + } + }, + "capture_valid": true, + "capture_invalid_reasons": [], + "tx_underruns_maximum": 0, + "tx_underruns_final": 0, + "rx_overruns_maximum": 0, + "rx_overruns_final": 0, + "rx_clipping": false +} diff --git a/data/processed/lab064/software_gate.json b/data/processed/lab064/software_gate.json new file mode 100644 index 0000000..8b0d7f6 --- /dev/null +++ b/data/processed/lab064/software_gate.json @@ -0,0 +1,39 @@ +{ + "lab": "Lab064", + "stage": "SOFTWARE_LINEARITY_GATE", + "rf_tx_count": 0, + "channel_model": "known linear complex gain plus DC and deterministic Gaussian noise", + "rows": [ + { + "label": "L0", + "matched_signal_power": 4.310961695783257, + "residual_power": 0.0018007645625517528, + "gain_abs": 0.37000937351312335 + }, + { + "label": "L1", + "matched_signal_power": 17.072568748673206, + "residual_power": 0.0017975392617719525, + "gain_abs": 0.3700018841553234 + }, + { + "label": "L2", + "matched_signal_power": 67.93699349861977, + "residual_power": 0.0017977879292746589, + "gain_abs": 0.37000113388875977 + }, + { + "label": "L3", + "matched_signal_power": 270.35275600931345, + "residual_power": 0.001803678589230678, + "gain_abs": 0.3700016234349186 + } + ], + "linearity_errors_db": [ + -2.3407130973929213e-05, + -8.27380804224731e-05, + -1.2603446491787906e-05 + ], + "passed": true, + "tx_sha256": "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9" +} diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index f53cb86..e79d5e9 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -624,3 +624,42 @@ [локальное сопоставление CSV](../data/processed/lab063/local_alignment.csv). - **STOP:** Lab063 не коммитировалась, push не выполнялся, Lab064 не создана. Следующий шаг требует отдельного согласования; RF TX=0. + +## Lab064: измерение уровня и линейности RF-тракта + +- **Запись 059; подготовка и RX-only, RF TX=0.** Создан один 0,75-секундный + PRBS7/BPSK/RRC waveform с четырьмя 100-мс участками −18/−12/−6/0 дБ, + промежутками по 50 мс и нулями по 100 мс в начале и конце. +- **Цифровые уровни:** RMS 5,556137/11,056978/22,056892/44,000457 кода; шаги + +5,977270/+5,998157/+5,998257 дБ; max |I|/|Q| не выше 69/69, clipping0. +- **Методика:** пакетный приёмник, FEC, CRC, T2 и JPEG не используются. Главная + величина — мощность согласованной с известным TX составляющей. Анализатор + выдаёт `INVALID_CAPTURE` без A/B/C при любом промежуточном underrun, overrun + или clipping. +- **Аппаратный preflight PASS:** MASTER/SLAVE reg29=0x26, SLAVE CLKIN=DETECTED + при первом опросе через66,793мс. RX-only:800000 отсчётов, полный RMS2,193193, + AC RMS0,832038, max|I|/|Q|=4/5, clipping0, overruns0, сильного фона нет. +- **Проверки:** quick gate984/37/13 PASS, pytest984 passed, целевые13 passed, + независимая read-only проверка чистая. +- [Текущее состояние](lab064_current_state.md), [отчёт](lab064_rx_preflight.md), + [подготовка](../data/processed/lab064/preparation.json), + [software gate](../data/processed/lab064/software_gate.json), + [preflight](../data/processed/lab064/preflight_result.json). +- **STOP:** технически готово к одному TX, но отдельного разрешения ещё нет. + Lab064 не коммитируется, push не выполняется, Lab065 не начинается. +- **Единственный RF-сеанс:** один TX, повторов0, underruns0, overruns0, + clipping0; RX16000000 отсчётов за2,0с. Фон стабилен: AC RMS + 0,718264…0,728217 кода, разброс0,119530дБ. +- **Линейность:** L0→L1/L1→L2/L2→L3 дали ошибки + −0,017501/−0,169270/+0,022162дБ. Полный рост TX/RX равен + 17,973685/17,809077дБ, общая ошибка −0,164609дБ. Признака компрессии нет. +- **Итог `LINEAR_BUT_WEAK`:** L3 matched/residual равен −12,133336дБ. Тракт + следует цифровым ступеням, но согласованная полезная составляющая остаётся + слабее остатка. [RF-отчёт](lab064_rf_result.md), + [RF JSON](../data/processed/lab064/rf_result.json), + [capture JSON](../data/processed/lab064/capture_result.json). +- **Текущее состояние:** `RF TX=0`, процессы CLEAR; второй TX не выполнялся, + Lab064 не коммитировалась, push не выполнялся, Lab065 не создана. +- **Независимая проверка:** прямой пересчёт из TX/RX CS8 подтвердил все SHA, + счётчики, фоны, matched/residual/`h`, ступени и спектральные полосы; + расхождений нет. Quick gate и pytest после RF:986 passed,0 failed. diff --git a/docs/lab064_current_state.md b/docs/lab064_current_state.md new file mode 100644 index 0000000..0e256cd --- /dev/null +++ b/docs/lab064_current_state.md @@ -0,0 +1,141 @@ +# Lab064: измерение уровня и линейности RF-тракта + +Lab064 проверяет, следует ли мощность согласованной принятой составляющей +заранее заданной цифровой лестнице TX. Это измерительный опыт с известным +PRBS7/BPSK-сигналом, а не пакетный тест. FEC, CRC, T2, JPEG и packet output в +нём не используются. + +На текущем этапе сформирован сигнал, подготовлены RX-first runner, офлайн- +анализатор и полный учёт сообщений overruns/underruns. Перед аппаратным +preflight и будущим RF-сеансом исходные параметры не меняются. + +## Физические условия + +| Условие | Значение | +|---|---| +| Центральная частота | 1400000000 Гц | +| Расстояние TX–RX | примерно 1,2 м | +| Геометрия | прямая видимость, помещение | +| TX | плата и антенна возле окна | +| RX | плата и антенна на столе пользователя | +| Антенны | имеющиеся антенны на 1400 МГц | +| Поляризация | одинаковая | +| Тактирование | 10 МГц TCXO → MASTER → MASTER CLKOUT → SLAVE CLKIN | + +Антенны, платы, длинный кабель общей опоры, USB-подключение и остальные +кабели не перемещаются. Во время опыта не меняются частота, усиления, +ориентация и форма сигнала. + +## Подготовленный измерительный сигнал + +Параметры сигнала: BPSK/PRBS7, 20000 символов/с, Fs 8000000 комплексных +отсчётов/с, 400 отсчётов/символ, цифровая несущая +50000 Гц и существующий RRC +с roll-off 0,35 и span 10 символов. TX VGA равен 10 дБ. RX LNA/VGA равны +24/24 дБ. RF amplifier и Bias-Tee выключены. + +Один файл содержит 100 мс нулей, четыре одинаковых по содержанию участка по +100 мс, три нулевых промежутка по 50 мс и 100 мс нулей в конце. Общая длина: +6000000 комплексных отсчётов, 12000000 байт, 0,75 с. Нулевые участки являются +цифровыми нулями IQ; они не означают аппаратного выключения TX посреди файла. + +SHA-256 подготовленного CS8: +`fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9`. + +| Уровень | Задано, дБ | Фактически, дБ | TX RMS, код | max \|I\| / \|Q\|, код | Шаг мощности, дБ | Clipping | +|---|---:|---:|---:|---:|---:|---| +| L0 | −18 | −17,973685 | 5,556137 | 9 / 9 | — | нет | +| L1 | −12 | −11,996415 | 11,056978 | 17 / 17 | +5,977270 | нет | +| L2 | −6 | −5,998257 | 22,056892 | 34 / 35 | +5,998157 | нет | +| L3 | 0 | 0 | 44,000457 | 69 / 69 | +5,998257 | нет | + +Небольшое отклонение L0 от −18 дБ вызвано квантованием CS8. Каждый шаг +укладывается в заранее установленный допуск ±0,1 дБ. + +## Анализ будущей записи + +Для каждого L0…L3 анализатор отдельно выдаёт полный и AC RMS, средние I/Q, +max |I|, max |Q| и максимальную комплексную амплитуду. По соседнему нулевому +окну оценивается фон. По известному TX оцениваются комплексный коэффициент +передачи `h`, `|h|²`, мощность согласованной составляющей, мощность остатка и +их отношение. + +Для каждой соседней пары вычисляются: + +```text +TX INPUT DIFFERENCE +RX MATCHED POWER DIFFERENCE +LINEARITY ERROR = RX difference − TX difference +``` + +Критерии зафиксированы до RF: + +- линейный участок: максимальная абсолютная ошибка шага не более 1,0 дБ; +- признак компрессии: ошибка верхнего шага не более −1,5 дБ; +- `LINEAR_BUT_WEAK`: шаги линейны, фон стабилен, а L3 ниже 3 дБ над остатком; +- `HEALTHY_LINEAR_RANGE`: шаги линейны, фон стабилен, а L3 не менее 6 дБ над + остатком; +- неоднозначный результат получает статус `NOT_LOCALIZED`. + +Эти границы являются правилами данного опыта, а не абсолютной калибровкой +RF-мощности. Абсолютную выходную и принятую мощность без измерительного прибора +установить нельзя. + +Синтетическая проверка известного линейного комплексного канала с DC и шумом +восстановила три цифровых шага с максимальной ошибкой меньше 0,0001 дБ. Это +проверяет вычисления анализатора, но не подтверждает аппаратные свойства +HackRF или эфирного канала. + +## Управление аппаратным сеансом + +Preflight допускает только RX-команды. Он проверяет MASTER/SLAVE reg29=0x26, +SLAVE CLKIN=DETECTED, процессы, инвентаризацию, 0,1 с фона, rail-коды, +overruns, AC RMS и максимумы |I|/|Q|. Усиления автоматически не меняются. + +Будущий runner запускает RX раньше TX. Передача требует отдельного явного +флага, создаёт одноразовый маркер до обращения к SDR и допускает ровно один +TX-вызов. Полные stdout и stderr сохраняются раздельно. В итог попадают все +промежуточные и финальные счётчики потерь, их максимум и последнее значение. +Любой underrun, overrun, clipping или аппаратная ошибка завершает опыт без +повтора. Анализатор при этом может сохранить диагностические метрики, но +выдаёт только `INVALID_CAPTURE`: варианты A/B/C запрещены. Проверка использует +все строки счётчиков и их максимум, поэтому последний ноль не может скрыть +предыдущее ненулевое значение. + +После исправления строгой проверки счётчиков quick gate прошёл: 984 теста +ядра, 37 функциональных проверок выполнены, 13 пропущены. Полный pytest: +984 passed, 0 failed. Независимая read-only проверка подтвердила подготовленный +CS8, программные метрики и запрет классификации невалидной записи. + +## Аппаратный preflight + +14 сентября 2026 года выполнена одна разрешённая аппаратная проверка при +выключенном передатчике. MASTER/SLAVE reg29 равны `0x26`, SLAVE CLKIN имеет +состояние `DETECTED` при первом опросе через 66,793 мс. RX-only запись длиной +800000 отсчётов дала AC RMS 0,832038 кода, max |I|/|Q| 4/5 кодов, clipping 0 и +overruns 0. Неожиданно сильного сигнала нет. После отключения CLKOUT процессы +имеют состояние `CLEAR`, ошибок очистки нет. Подробности приведены в +[отчёте preflight](lab064_rx_preflight.md). + +`RF TX = 0`. На этом этапе Lab064 не коммитируется. Push не выполняется. +Lab065 автоматически не начинается. + +## Фактический RF-результат + +14 сентября 2026 года выполнен один разрешённый RF-сеанс без повторов. TX +underruns 0, RX overruns 0, clipping 0; RX содержит 16000000 комплексных +отсчётов за 2,0 с. Согласованная мощность выросла на 17,809077 дБ при цифровом +росте 17,973685 дБ. Общая ошибка линейности равна −0,164609 дБ. Верхняя ступень +дала +6,020420 дБ при ожидаемых +5,998257 дБ, признака компрессии нет. + +Фон стабилен: AC RMS 0,718264…0,728217 кода, разброс мощности 0,119530 дБ. +Даже L3 остаётся на −12,133336 дБ относительно остатка. Итог по заранее +заданным критериям: `LINEAR_BUT_WEAK`. Подробные значения приведены в +[RF-отчёте](lab064_rf_result.md). + +После сеанса `RF TX = 0`, процессы `CLEAR`. Усиления не менялись, второй TX не +выполнялся. Lab064 не коммитировалась, push не выполнялся, Lab065 не создана. + +Независимый пересчёт непосредственно по TX/RX CS8 подтвердил SHA, аппаратные +счётчики, все четыре matched/residual/`h`, пять окон фона, три ступени и общий +результат L3/L0. Расхождений нет, вердикт проверки: `чисто`. После сеанса quick +gate и полный pytest дали 986 passed, 0 failed; `git diff --check` прошёл. diff --git a/docs/lab064_rf_result.md b/docs/lab064_rf_result.md new file mode 100644 index 0000000..13521ce --- /dev/null +++ b/docs/lab064_rf_result.md @@ -0,0 +1,216 @@ +# Lab064: результат измерения уровня и линейности RF-тракта + +В одном коротком OTA-сеансе цифровая мощность TX была увеличена почти на 18 дБ, +то есть цифровая амплитуда выросла примерно в 7,92 раза. Согласованная с +известным TX амплитуда на RX выросла в 7,77 раза, а её мощность на 17,81 дБ. +Коэффициент передачи `|h|` почти не менялся. Признака компрессии на верхнем +уровне не обнаружено. + +При этом полезная согласованная составляющая остаётся слабее остатка даже на +L3: −12,13 дБ. Фон во всех пяти нулевых окнах стабилен. По заранее заданным +критериям результат классифицирован как `LINEAR_BUT_WEAK`. + +Опыт установил относительную линейность именно этой цифровой лестницы в +текущей OTA-геометрии. Он не измеряет абсолютную мощность в ваттах или дБм и не +разделяет вклад TX, антенн, эфирного канала и RX. Для этого нужен измерительный +прибор или отдельный проведённый тест по кабелю. + +## Сеанс и аппаратная целостность + +14 сентября 2026 года выполнен ровно один разрешённый TX. RX стартовал раньше +TX. Повторных передач не было. + +| Проверка | Результат | +|---|---:| +| TX waveform SHA-256 | `fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9` | +| RF / Fs / symbol rate | 1400000000 Гц / 8000000 отсчётов/с / 20000 символов/с | +| TX VGA / RX LNA / RX VGA | 10 / 24 / 24 дБ | +| RF amplifier / Bias-Tee | выключены | +| MASTER / SLAVE reg29 | `0x26` / `0x26` | +| SLAVE CLKIN | `DETECTED`, второй опрос, 139,042 мс | +| Выполненные TX-сеансы | 1 | +| Автоматические повторы | 0 | +| TX underrun, максимум / финал | 0 / 0 | +| RX overrun, максимум / финал | 0 / 0 | +| RX clipping | 0 | +| RX длина | 16000000 комплексных отсчётов, 2,000000 с | +| RX SHA-256 | `02015891948aaee52ff81e60fdfeb391bb9b76cd9192b75972b8fedbc8690d86` | +| Процессы до / после | `CLEAR` / `CLEAR` | +| Ошибки очистки | 0 | + +В TX-журнале сохранены две строки `0 underruns`; в RX-журнале сохранены четыре +строки `0 overruns`. Анализ использует все наблюдения и их максимум. Финальный +нулевой счётчик не мог бы скрыть промежуточное ненулевое значение. + +## Фон + +Каждое окно имеет длину 160000 отсчётов, или 20 мс, и лежит внутри цифрового +нулевого участка вдали от активной границы. + +| Окно | AC power, код² | AC RMS, код | +|---|---:|---:| +| Начальный guard | 0,521529 | 0,722170 | +| Gap L0–L1 | 0,523180 | 0,723312 | +| Gap L1–L2 | 0,515904 | 0,718264 | +| Gap L2–L3 | 0,522200 | 0,722634 | +| Конечный guard | 0,530300 | 0,728217 | + +Диапазон AC RMS равен 0,718264…0,728217 кода. Отношение максимальной AC-мощности +к минимальной составляет 0,119530 дБ при заранее заданной границе стабильности +3 дБ. Фон считается стабильным. + +## Метрики участков + +Мощности приведены в квадрате цифрового кода RX. Это относительные цифровые +величины, а не ватты и не дБм. + +### L0, −18 дБ + +```text +FULL IQ RMS = 2.277200 code +AC RMS = 0.956317 code +MEAN I = 0.949724 code +MEAN Q = 1.835518 code +MAX |I| = 6 code +MAX |Q| = 7 code +MAX COMPLEX MAGNITUDE = 7.071068 code +BACKGROUND AC POWER = 0.521529 code^2 +MATCHED SIGNAL POWER = 0.000750751 code^2 +RESIDUAL POWER = 1.213794804 code^2 +MATCHED / RESIDUAL = -32.086493 dB +h = 0.001876064 - j0.004508063 +|h| = 0.004882853 +|h|^2 = 0.000023842249 +CFO = +0.095772 Hz +``` + +### L1, −12 дБ + +```text +FULL IQ RMS = 2.291392 code +AC RMS = 0.989014 code +MEAN I = 0.956038 code +MEAN Q = 1.832573 code +MAX |I| = 6 code +MAX |Q| = 7 code +MAX COMPLEX MAGNITUDE = 7.211103 code +BACKGROUND AC POWER = 0.523180 code^2 +MATCHED SIGNAL POWER = 0.002961235 code^2 +RESIDUAL POWER = 1.069594603 code^2 +MATCHED / RESIDUAL = -25.577463 dB +h = 0.004471119 - j0.001937686 +|h| = 0.004872939 +|h|^2 = 0.000023745532 +CFO = -0.168571 Hz +``` + +### L2, −6 дБ + +```text +FULL IQ RMS = 2.275060 code +AC RMS = 0.925298 code +MEAN I = 0.963183 code +MEAN Q = 1.841739 code +MAX |I| = 6 code +MAX |Q| = 7 code +MAX COMPLEX MAGNITUDE = 7.280110 code +BACKGROUND AC POWER = 0.515904 code^2 +MATCHED SIGNAL POWER = 0.011333436 code^2 +RESIDUAL POWER = 0.529088688 code^2 +MATCHED / RESIDUAL = -16.691669 dB +h = 0.003277253 - j0.003478187 +|h| = 0.004778930 +|h|^2 = 0.000022838175 +CFO = +0.006742 Hz +``` + +### L3, 0 дБ + +```text +FULL IQ RMS = 2.341160 code +AC RMS = 1.057677 code +MEAN I = 0.957558 code +MEAN Q = 1.856188 code +MAX |I| = 6 code +MAX |Q| = 7 code +MAX COMPLEX MAGNITUDE = 7.280110 code +BACKGROUND AC POWER = 0.522200 code^2 +MATCHED SIGNAL POWER = 0.045331864 code^2 +RESIDUAL POWER = 0.740861774 code^2 +MATCHED / RESIDUAL = -12.133336 dB +h = 0.003384388 - j0.003391322 +|h| = 0.004791153 +|h|^2 = 0.000022955145 +CFO = -0.076067 Hz +``` + +Максимумы активных участков равны `|I|=6` и `|Q|=7`. Полная двухсекундная +запись также далека от rail-кодов: `|I|=7`, `|Q|=8`. Это исключает цифровой +clipping в записи, но не является самостоятельным доказательством идеальной +линейности аналогового тракта. + +## Линейность мощности и амплитуды + +| Пара | TX, дБ | RX matched, дБ | Ошибка, дБ | TX amplitude ratio | RX matched amplitude ratio | +|---|---:|---:|---:|---:|---:| +| L0 → L1 | +5,977270 | +5,959770 | −0,017501 | 1,990048 | 1,986042 | +| L1 → L2 | +5,998157 | +5,828887 | −0,169270 | 1,994839 | 1,956340 | +| L2 → L3 | +5,998257 | +6,020420 | +0,022162 | 1,994862 | 1,999959 | +| L0 → L3 | +17,973685 | +17,809077 | −0,164609 | 7,919254 | 7,770587 | + +Максимальная абсолютная ошибка отдельной ступени равна 0,169270 дБ и заметно +меньше заранее установленной границы линейности 1,0 дБ. Верхняя ступень не +отстаёт от TX: она выросла на 6,020420 дБ при ожидаемых 5,998257 дБ. Порог +признака компрессии составлял −1,5 дБ ошибки верхней ступени; фактическая +ошибка равна +0,022162 дБ. + +`|h|` изменился от 0,004882853 на L0 до 0,004791153 на L3. Это соответствует +−0,164609 дБ по мощности и совпадает с общей ошибкой линейности. Систематического +падения коэффициента передачи на верхнем уровне нет. + +Остаточная мощность при переходе L2 → L3 выросла примерно на 1,46 дБ, тогда +как согласованная полезная мощность выросла на 6,02 дБ. Отношение +matched/residual улучшилось с −16,69 до −12,13 дБ. Ухудшения формы сигнала на +L3 относительно L2 по этому показателю нет. + +Диагностические полосы остатка около 50/100/150 кГц на L3 равны +0,009576/0,004469/0,002725 код². Они не растут быстрее согласованной мощности +при переходе L2 → L3. Эти полосы не являются калиброванным измерением +RF-гармоник, поэтому абсолютный вывод о спектральной чистоте потребовал бы +анализатора спектра. + +## Вывод + +```text +CLASSIFICATION = LINEAR_BUT_WEAK +``` + +Текущий тракт воспроизводит цифровые ступени почти один к одному по дБ. Данных +за компрессию при TX VGA 10 дБ и RX LNA/VGA 24/24 дБ нет. Главная проблема +этого сеанса состоит в низкой согласованной полезной мощности относительно +остатка: даже на L3 запас равен −12,13 дБ. + +Практически это означает, что простое объяснение Lab062 аналоговой перегрузкой +текущего тракта не подтверждается. Слабое отношение полезной составляющей к +остатку остаётся вероятным фактором плохого 500K-приёма, хотя Lab064 использует +узкий сигнал 20 ksymbol/s и сама по себе не объясняет все широкополосные +искажения Lab062. + +Рекомендуемый следующий отдельный шаг: согласовать повтор той же лестницы при +TX VGA 20 дБ, сохранив RX LNA/VGA 24/24 дБ, частоту, геометрию, waveform и +общую опору. Это проверит, улучшается ли matched/residual при сохранении +линейности. В рамках Lab064 усиления не менялись и второй TX не выполнялся. + +## Независимая проверка + +Проверяющий агент работал только с сохранёнными файлами и не обращался к +оборудованию. Он независимо пересчитал SHA-256, положение четырёх сегментов, +пять окон фона, matched/residual, комплексные `h`, отношения мощности и +амплитуды, общий результат L3/L0 и все двенадцать спектральных значений. +Подтверждённых расхождений нет. Целевые тесты дали 15 passed; итоговый вердикт: +`чисто`. + +После RF-сеанса quick gate прошёл: 986 тестов ядра, 37 функциональных проверок, +13 пропусков. Полный pytest: 986 passed, 0 failed. `git diff --check`: PASS. + +`RF TX = 0`. Lab064 не коммитировалась, push не выполнялся, Lab065 не создана. diff --git a/docs/lab064_rx_preflight.md b/docs/lab064_rx_preflight.md new file mode 100644 index 0000000..02f1054 --- /dev/null +++ b/docs/lab064_rx_preflight.md @@ -0,0 +1,120 @@ +# Lab064: подготовка и RX-only preflight + +14 сентября 2026 года завершена программная подготовка измерения уровня и +линейности текущего OTA-тракта. Затем выполнена одна аппаратная проверка общей +опоры и RX-фона. Preflight прошёл. Передатчик не запускался: `RF TX = 0`. + +## Условия + +| Параметр | Значение | +|---|---| +| Центральная частота | 1400000000 Гц | +| Скорость символов | 20000 символов/с | +| Частота дискретизации | 8000000 комплексных отсчётов/с | +| Цифровая несущая | +50000 Гц | +| TX VGA | 10 дБ | +| RX LNA / VGA | 24 / 24 дБ | +| RF amplifier / Bias-Tee | выключены | +| Расстояние TX–RX | примерно 1,2 м | +| Геометрия | прямая видимость, помещение | +| TX | плата и антенна возле окна | +| RX | плата и антенна на столе пользователя | +| Антенны | имеющиеся антенны на 1400 МГц | +| Поляризация | одинаковая | +| Общая опора | 10 МГц TCXO → MASTER → MASTER CLKOUT → SLAVE CLKIN | + +Антенны, платы, длинный кабель общей опоры, USB-подключение и остальные +кабели не перемещались. Усиления не менялись. + +## Измерительный TX-файл + +Подготовлен один детерминированный PRBS7/BPSK/RRC-сигнал с уровнями −18, −12, +−6 и 0 дБ относительно номинального waveform. Он состоит из 100 мс начальных +нулей, четырёх участков по 100 мс, трёх промежутков по 50 мс и 100 мс конечных +нулей. Полная длительность равна 0,75 с, длина составляет 6000000 комплексных +отсчётов и 12000000 байт. + +| Уровень | TX RMS, код | max \|I\| / \|Q\|, код | Фактический уровень, дБ | Шаг, дБ | +|---|---:|---:|---:|---:| +| L0 | 5,556137 | 9 / 9 | −17,973685 | — | +| L1 | 11,056978 | 17 / 17 | −11,996415 | +5,977270 | +| L2 | 22,056892 | 34 / 35 | −5,998257 | +5,998157 | +| L3 | 44,000457 | 69 / 69 | 0 | +5,998257 | + +Цифрового clipping нет. SHA-256: +`fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9`. + +Синтетический линейный канал с известным комплексным коэффициентом, DC и +шумом прошёл software gate. После независимой проверки анализатор изменён так, +чтобы промежуточный ненулевой счётчик нельзя было скрыть финальным нулём. При +любой потере сохраняются диагностические метрики, но A/B/C-классификация +запрещается и результат получает статус `INVALID_CAPTURE`. + +## Common clock + +Свежая инвентаризация подтвердила две HackRF One r9 с firmware/libhackrf +2024.02.1. Роли сохранены: + +- MASTER `000000000000000097c063dc323b97d3`; +- SLAVE `000000000000000097c063dc2e5327d3`. + +Аппаратная проверка получила: + +```text +MASTER reg29 = 0x26 +SLAVE reg29 = 0x26 +SLAVE CLKIN = DETECTED +CLKIN query = 1 +time to detect = 66.793 ms +``` + +## RX-only фон + +SLAVE записал 800000 комплексных отсчётов, 0,1 с, при 1400 МГц и RX LNA/VGA +24/24 дБ. + +| Метрика | Значение | +|---|---:| +| Полный RMS IQ | 2,193193 кода | +| AC RMS после удаления среднего | 0,832038 кода | +| Средние I / Q | 0,933738 / 1,801650 кода | +| max \|I\| / \|Q\| | 4 / 5 кодов | +| Максимальная комплексная амплитуда | 5,385165 кода | +| Clipping | 0 / 800000 отсчётов | +| RX overruns | 0, максимум и финальное значение | +| Неожиданно сильный сигнал | нет | + +Порог STOP для неожиданно сильного цифрового фона: AC RMS не менее 20 кодов +или max |I|/|Q| не менее 100 кодов. Измеренные значения значительно ниже. +Отсутствие rail-кодов не доказывает отсутствие аналоговой компрессии при +будущей передаче. Абсолютная RF-мощность без измерительного прибора неизвестна. + +SHA-256 RX-only записи: +`02d87adcbb621027290a77d5f8fd3190c299d13e89f887cd294d4af352a6ef52`. + +Процессы до и после preflight имели состояние `CLEAR`. Оба CLKOUT выключены, +ошибок очистки нет. Preflight сохранил все две строки счётчика RX overruns; +обе равны нулю. Вызовов `hackrf_transfer -t` не было, `rf_tx_count = 0`. + +## Проверки и артефакты + +- quick gate: 984 теста ядра, 37 функциональных проверок, 13 пропусков; +- pytest: 984 passed, 0 failed; +- целевые тесты Lab064: 13 passed; +- `git diff --check`: PASS; +- независимая read-only проверка: чисто. + +Артефакты: + +- [подготовка](../data/processed/lab064/preparation.json); +- [software gate](../data/processed/lab064/software_gate.json); +- [preflight](../data/processed/lab064/preflight_result.json); +- CS8 и исходные аппаратные журналы находятся в `data/raw/lab064/` и в Git не + входят. + +`READY FOR SINGLE LAB064 TX = YES` означает техническую готовность только этой +конфигурации. Передача требует отдельного явного разрешения пользователя. +`RF TX = 0`. STOP. + +Lab064 не коммитируется до аппаратного результата. Push не выполняется. +Lab065 автоматически не начинается. diff --git a/experiments/lab064_rf_level_linearity.py b/experiments/lab064_rf_level_linearity.py new file mode 100644 index 0000000..1a8fe54 --- /dev/null +++ b/experiments/lab064_rf_level_linearity.py @@ -0,0 +1,957 @@ +"""Lab064: лестница цифровых уровней и измерение линейности RF-тракта. + +Подготовка и анализ не используют пакетный приёмник. Аппаратный preflight +работает только в режиме RX. Передача доступна отдельной командой с явным +флагом и защищена одноразовым маркером. +""" + +from __future__ import annotations + +from dataclasses import asdict +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys +import time + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np +from scipy import signal + +from experiments import lab043_pluto_to_rtlsdr as shaping +from experiments import lab054_hardware_control as lab054 +from experiments import lab055_energy_audit as energy +from experiments import lab056_abab_energy as lab056 +from experiments import lab056_capture_runner as hardware + + +ROOT = Path(__file__).resolve().parents[1] +RAW = ROOT / "data/raw/lab064" +OUTPUT = ROOT / "data/processed/lab064" + +SAMPLE_RATE = 8_000_000 +SYMBOL_RATE = 20_000 +SAMPLES_PER_SYMBOL = SAMPLE_RATE // SYMBOL_RATE +CARRIER_HZ = 50_000 +NOMINAL_RMS_CODE = 44.0 +LEVELS = (("L0", -18.0), ("L1", -12.0), ("L2", -6.0), ("L3", 0.0)) +SEGMENT_SAMPLES = 800_000 +GAP_SAMPLES = 400_000 +LEADING_ZERO_SAMPLES = 800_000 +TRAILING_ZERO_SAMPLES = 800_000 +PRBS_SYMBOLS = SEGMENT_SAMPLES // SAMPLES_PER_SYMBOL - 42 +TRIM_SAMPLES = 21 * SAMPLES_PER_SYMBOL +TX_SAMPLES = ( + LEADING_ZERO_SAMPLES + + len(LEVELS) * SEGMENT_SAMPLES + + (len(LEVELS) - 1) * GAP_SAMPLES + + TRAILING_ZERO_SAMPLES +) +RX_SECONDS = 2.0 +RX_SAMPLES = int(RX_SECONDS * SAMPLE_RATE) +RX_LEAD_SECONDS = 0.5 +PREFLIGHT_SAMPLES = 800_000 +STRONG_AC_RMS_CODE = 20.0 +STRONG_COMPONENT_CODE = 100.0 +BACKGROUND_WINDOW_SAMPLES = 160_000 + +POINT = hardware.OperatingPoint( + frequency_hz=1_400_000_000, + physical_path="MASTER TX near window -> LOS indoor -> SLAVE RX on desk", + path_confirmed=True, + tx_antenna="existing 1400 MHz antenna near window", + rx_antenna="existing 1400 MHz antenna on desk", + distance_m=1.2, + orientation="same polarization; fixed during preflight and RF session", + visibility="LOS indoor", +) + + +def save_new(path: Path, value: dict) -> None: + """Записать новый JSON и не перезаписывать свидетельства опыта.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("x", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False) + stream.write("\n") + + +def save_text_new(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("x", encoding="utf-8") as stream: + stream.write(value) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def parameters() -> dict: + return { + "rf_frequency_hz": POINT.frequency_hz, + "sample_rate_hz": SAMPLE_RATE, + "symbol_rate": SYMBOL_RATE, + "samples_per_symbol": SAMPLES_PER_SYMBOL, + "digital_carrier_hz": CARRIER_HZ, + "rrc_rolloff": shaping.LAB042_RRC_ROLLOFF, + "rrc_span_symbols": shaping.LAB042_RRC_SPAN_SYMBOLS, + "prbs": "PRBS7 x^7+x^6+1, deterministic initial state", + "full_phy_packet": False, + "tx_vga_db": 10, + "rx_lna_db": 24, + "rx_vga_db": 24, + "rf_amplifier": False, + "bias_tee": False, + "geometry": asdict(POINT), + "clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN", + "levels_db_relative_nominal": [level for _, level in LEVELS], + } + + +def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]: + return hardware.command(POINT, path, samples, transmit=transmit) + + +def _complex_to_cs8(values: np.ndarray) -> np.ndarray: + interleaved = np.empty(2 * len(values), dtype=np.int8) + interleaved[::2] = np.clip(np.rint(values.real), -128, 127).astype(np.int8) + interleaved[1::2] = np.clip(np.rint(values.imag), -128, 127).astype(np.int8) + return interleaved + + +def nominal_segment() -> np.ndarray: + """Один участок длиной 100 мс с прежними PRBS7, BPSK и RRC.""" + symbols = 2.0 * shaping.pilot_prbs7(PRBS_SYMBOLS) - 1.0 + waveform = shaping.shape_frame_like_lab042(symbols, SAMPLES_PER_SYMBOL) + if len(waveform) != SEGMENT_SAMPLES: + raise RuntimeError("RRC-участок не равен заранее заданным 100 мс") + carrier = np.exp(2j * np.pi * CARRIER_HZ * np.arange(len(waveform)) / SAMPLE_RATE) + waveform = waveform * carrier + waveform *= NOMINAL_RMS_CODE / np.sqrt(np.mean(np.abs(waveform) ** 2)) + return waveform + + +def level_segment(level_db: float) -> np.ndarray: + scale = 10 ** (level_db / 20) + return _complex_to_cs8(nominal_segment() * scale) + + +def segment_metrics(interleaved: np.ndarray, level_db: float) -> dict: + values = interleaved[::2].astype(np.float32) + 1j * interleaved[1::2].astype(np.float32) + moments = energy.Moments() + moments.add(values) + result = moments.result(SYMBOL_RATE) + result.update({ + "requested_relative_power_db": level_db, + "tx_rms_code": result["rms_code"], + "tx_peak_abs_i_code": result["max_abs_i"], + "tx_peak_abs_q_code": result["max_abs_q"], + "tx_peak_magnitude_code": result["peak_magnitude_code"], + "digital_clipping": bool(result["clipped_samples_fraction"] > 0), + }) + return result + + +def prepared_segments() -> tuple[list[dict], dict[str, np.ndarray]]: + cursor = LEADING_ZERO_SAMPLES + rows: list[dict] = [] + blocks: dict[str, np.ndarray] = {} + for index, (label, level_db) in enumerate(LEVELS): + block = level_segment(level_db) + metrics = segment_metrics(block, level_db) + rows.append({ + "label": label, + "level_db": level_db, + "start_sample": cursor, + "samples": SEGMENT_SAMPLES, + "duration_seconds": SEGMENT_SAMPLES / SAMPLE_RATE, + "analysis_start_sample": cursor + TRIM_SAMPLES, + "analysis_samples": SEGMENT_SAMPLES - 2 * TRIM_SAMPLES, + "metrics": metrics, + }) + blocks[label] = block + cursor += SEGMENT_SAMPLES + if index < len(LEVELS) - 1: + cursor += GAP_SAMPLES + if cursor + TRAILING_ZERO_SAMPLES != TX_SAMPLES: + raise RuntimeError("Ошибка схемы TX waveform") + nominal_power = rows[-1]["metrics"]["power_code2"] + for row in rows: + row["metrics"]["actual_relative_power_db"] = float( + 10 * np.log10(row["metrics"]["power_code2"] / nominal_power) + ) + for lower, upper in zip(rows, rows[1:]): + upper["tx_input_difference_from_previous_db"] = float( + 10 * np.log10( + upper["metrics"]["power_code2"] / lower["metrics"]["power_code2"] + ) + ) + return rows, blocks + + +def prepare() -> dict: + """Создать единственный конечный TX-файл и его проверяемые метаданные.""" + RAW.mkdir(parents=True, exist_ok=True) + OUTPUT.mkdir(parents=True, exist_ok=True) + tx_path = RAW / "tx_level_staircase.cs8" + rows, blocks = prepared_segments() + with tx_path.open("xb") as stream: + stream.write(bytes(2 * LEADING_ZERO_SAMPLES)) + for index, (label, _) in enumerate(LEVELS): + stream.write(blocks[label].tobytes()) + if index < len(LEVELS) - 1: + stream.write(bytes(2 * GAP_SAMPLES)) + stream.write(bytes(2 * TRAILING_ZERO_SAMPLES)) + if tx_path.stat().st_size != 2 * TX_SAMPLES: + raise RuntimeError("Неверная длина подготовленного TX") + if any(row["metrics"]["digital_clipping"] for row in rows): + raise RuntimeError("В подготовленном TX обнаружен цифровой clipping") + steps = [row["tx_input_difference_from_previous_db"] for row in rows[1:]] + if any(abs(step - 6.0) > 0.1 for step in steps): + raise RuntimeError("Цифровая лестница не выдерживает шаг +6 дБ") + result = { + "lab": "Lab064", + "stage": "PREPARATION_ONLY", + "rf_tx_count": 0, + "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "parameters": parameters(), + "segments": rows, + "leading_zero_samples": LEADING_ZERO_SAMPLES, + "gap_samples": GAP_SAMPLES, + "trailing_zero_samples": TRAILING_ZERO_SAMPLES, + "tx_samples": TX_SAMPLES, + "tx_bytes": 2 * TX_SAMPLES, + "tx_duration_seconds": TX_SAMPLES / SAMPLE_RATE, + "tx_sha256": sha256(tx_path), + "rx_samples": RX_SAMPLES, + "rx_duration_seconds": RX_SECONDS, + "rx_lead_seconds": RX_LEAD_SECONDS, + "automatic_retry": False, + "tx_command_prepared_not_run": transfer_command(tx_path, TX_SAMPLES, transmit=True), + "rx_command_prepared_not_run": transfer_command(RAW / "rx_stream.cs8", RX_SAMPLES), + } + save_new(RAW / "tx_metadata.json", result) + save_new(OUTPUT / "preparation.json", result) + return result + + +def validate_prepared() -> dict: + metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8")) + path = RAW / "tx_level_staircase.cs8" + rows, blocks = prepared_segments() + digest = hashlib.sha256() + digest.update(bytes(2 * LEADING_ZERO_SAMPLES)) + for index, (label, _) in enumerate(LEVELS): + digest.update(blocks[label].tobytes()) + if index < len(LEVELS) - 1: + digest.update(bytes(2 * GAP_SAMPLES)) + digest.update(bytes(2 * TRAILING_ZERO_SAMPLES)) + if ( + metadata.get("lab") != "Lab064" + or metadata.get("stage") != "PREPARATION_ONLY" + or metadata.get("rf_tx_count") != 0 + or metadata.get("parameters") != parameters() + or metadata.get("segments") != rows + or metadata.get("tx_samples") != TX_SAMPLES + or path.stat().st_size != 2 * TX_SAMPLES + or metadata.get("tx_sha256") != digest.hexdigest() + or sha256(path) != digest.hexdigest() + ): + raise RuntimeError("Подготовленный Lab064 TX или параметры изменились") + return metadata + + +def difference_db(new: float, old: float) -> float: + if new <= 0 or old <= 0: + raise ValueError("Для разности в дБ нужны положительные мощности") + return float(10 * np.log10(new / old)) + + +def counter_observations(log: str) -> list[dict]: + """Сохранить все промежуточные и итоговые счётчики потерь из лога.""" + pattern = re.compile( + r"(? dict: + observations = counter_observations(log) + selected = [row for row in observations if row["kind"] == expected_kind] + unparsed = re.search( + r"overrun|underrun", + re.sub( + r"(? dict: + """Проверить метрики на известном линейном канале без аппаратуры.""" + rows, blocks = prepared_segments() + random_generator = np.random.default_rng(64064) + measured = [] + for row in rows: + block = blocks[row["label"]] + reference = block[::2].astype(np.float64) + 1j * block[1::2].astype(np.float64) + reference = reference[TRIM_SAMPLES:-TRIM_SAMPLES] + noise = random_generator.normal(0, 0.03, len(reference)) + 1j * random_generator.normal( + 0, 0.03, len(reference) + ) + received = 0.37 * np.exp(0.41j) * reference + (0.8 - 0.3j) + noise + fitted = energy.fit_channel(reference, received, SAMPLE_RATE) + measured.append({ + "label": row["label"], + "matched_signal_power": fitted["heldout_signal_power"], + "residual_power": fitted["heldout_residual_power"], + "gain_abs": fitted["gain_abs"], + }) + step_errors = [] + for index in range(1, len(rows)): + tx_step = rows[index]["tx_input_difference_from_previous_db"] + rx_step = difference_db( + measured[index]["matched_signal_power"], + measured[index - 1]["matched_signal_power"], + ) + step_errors.append(rx_step - tx_step) + passed = max(abs(error) for error in step_errors) < 0.02 + return { + "lab": "Lab064", + "stage": "SOFTWARE_LINEARITY_GATE", + "rf_tx_count": 0, + "channel_model": "known linear complex gain plus DC and deterministic Gaussian noise", + "rows": measured, + "linearity_errors_db": step_errors, + "passed": passed, + } + + +def software_gate() -> dict: + metadata = validate_prepared() + result = synthetic_linearity_gate() + result["tx_sha256"] = metadata["tx_sha256"] + save_new(OUTPUT / "software_gate.json", result) + if not result["passed"]: + raise RuntimeError("Lab064 software gate FAIL") + return result + + +def locate_waveform(tx: energy.Cs8File, rx: energy.Cs8File) -> dict: + """Найти всю лестницу по прореженной диагностической корреляции.""" + stride = 32 + + def decimated(source: energy.Cs8File) -> np.ndarray: + chunks = [ + source.read(start, min(262_144, source.size - start))[::stride] + for start in range(0, source.size, 262_144) + ] + return np.concatenate(chunks).astype(np.complex128) + + reference = decimated(tx) + received = decimated(rx) + if len(received) < len(reference): + raise ValueError("RX короче TX waveform") + reference -= reference.mean() + received -= received.mean() + reference_energy = float(np.sum(np.abs(reference) ** 2)) + cumulative = np.concatenate(([0.0], np.cumsum(np.abs(received) ** 2))) + window_energy = cumulative[len(reference):] - cumulative[:-len(reference)] + time_axis = np.arange(len(reference)) * stride / SAMPLE_RATE + best = (-1.0, 0, 0.0) + for frequency_hz in np.arange(-3.0, 3.01, 0.25): + rotated = reference * np.exp(2j * np.pi * frequency_hz * time_axis) + correlation = signal.fftconvolve(received, np.conj(rotated[::-1]), mode="valid") + scores = np.abs(correlation) / np.sqrt( + np.maximum(reference_energy * window_energy, 1e-30) + ) + index = int(np.argmax(scores)) + if scores[index] > best[0]: + best = (float(scores[index]), index * stride, float(frequency_hz)) + if best[0] < 0.05: + raise RuntimeError("Лестница уровней не локализована; измерение UNKNOWN") + return {"start_sample": best[1], "correlation": best[0], "coarse_cfo_hz": best[2], "stride": stride} + + +def _window_metrics(values: np.ndarray) -> dict: + moments = energy.Moments() + moments.add(values) + result = moments.result(SYMBOL_RATE) + result["ac_rms_code"] = float(np.sqrt(max(0.0, result["ac_power_code2"]))) + return result + + +def background_windows() -> list[dict]: + """Окна внутри всех нулевых участков, вдали от активных границ.""" + starts = ( + ("INITIAL_GUARD", 320_000), + ("GAP_L0_L1", 1_760_000), + ("GAP_L1_L2", 2_960_000), + ("GAP_L2_L3", 4_160_000), + ("FINAL_GUARD", 5_440_000), + ) + return [ + { + "label": label, + "tx_relative_start_sample": start, + "samples": BACKGROUND_WINDOW_SAMPLES, + "duration_seconds": BACKGROUND_WINDOW_SAMPLES / SAMPLE_RATE, + } + for label, start in starts + ] + + +def summarize_background(rows: list[dict]) -> dict: + powers = [row["metrics"]["ac_power_code2"] for row in rows] + rms_values = [row["metrics"]["ac_rms_code"] for row in rows] + variation_db = difference_db(max(powers), min(powers)) + return { + "ac_power_min_code2": min(powers), + "ac_power_max_code2": max(powers), + "ac_rms_min_code": min(rms_values), + "ac_rms_max_code": max(rms_values), + "maximum_to_minimum_ac_power_db": variation_db, + "stability_rule_db": 3.0, + "stable": variation_db <= 3.0, + } + + +def residual_spectral_diagnostics( + reference: np.ndarray, + received: np.ndarray, + cfo_hz: float, + complex_gain: dict, +) -> dict: + """Оценить паразитные полосы после вычитания согласованной составляющей.""" + rotated = reference * np.exp(2j * np.pi * cfo_hz * np.arange(len(reference)) / SAMPLE_RATE) + rotated -= rotated.mean() + centered_received = received - received.mean() + gain = complex(complex_gain["real"], complex_gain["imag"]) + residual = centered_received - gain * rotated + frequencies, density = energy.spectrum(residual) + half_width = 0.675 * SYMBOL_RATE + bands = {} + for harmonic in (1, 2, 3): + center = harmonic * CARRIER_HZ + bands[f"residual_band_h{harmonic}_power_code2"] = energy.band_power( + frequencies, density, center - half_width, center + half_width + ) + bands["interpretation"] = ( + "Diagnostic residual bands at 50/100/150 kHz; not calibrated RF harmonic power" + ) + return bands + + +def classify_linearity( + rows: list[dict], comparisons: list[dict], background_summary: dict +) -> dict: + errors = [row["linearity_error_db"] for row in comparisons] + l3_ratio = rows[-1]["matched_to_residual_db"] + clipping = any(row["full_iq"]["clipped_samples_fraction"] > 0 for row in rows) + if clipping: + case = "INVALID_RX_CLIPPING" + elif comparisons[-1]["linearity_error_db"] <= -1.5: + case = "COMPRESSION" + elif ( + max(abs(value) for value in errors) <= 1.0 + and l3_ratio < 3.0 + and background_summary["stable"] + ): + case = "LINEAR_BUT_WEAK" + elif ( + max(abs(value) for value in errors) <= 1.0 + and l3_ratio >= 6.0 + and background_summary["stable"] + ): + case = "HEALTHY_LINEAR_RANGE" + else: + case = "NOT_LOCALIZED" + return { + "case": case, + "rules_fixed_before_rf": { + "linear_max_abs_error_db": 1.0, + "compression_upper_step_error_at_most_db": -1.5, + "weak_l3_matched_to_residual_below_db": 3.0, + "healthy_l3_matched_to_residual_at_least_db": 6.0, + }, + } + + +def capture_validity(capture: dict) -> dict: + """Не дать последнему нулевому счётчику скрыть промежуточную потерю.""" + reasons = [] + for role, key in (("TX", "tx_counter_summary"), ("RX", "rx_counter_summary")): + summary = capture.get(key, {}) + if summary.get("clean") is not True: + reasons.append(f"{role}_COUNTER_NOT_CLEAN") + if summary.get("maximum_count") != 0: + reasons.append(f"{role}_MAXIMUM_COUNTER_NOT_ZERO") + if capture.get("passed") is not True: + reasons.append("CAPTURE_RUNNER_NOT_PASSED") + if capture.get("rx_clipping") is not False: + reasons.append("RX_CLIPPING_NOT_EXCLUDED") + return {"valid": not reasons, "invalid_reasons": reasons} + + +def analyze_capture() -> dict: + """Проанализировать сохранённый RX только как известный измерительный сигнал.""" + metadata = validate_prepared() + rx_path = RAW / "rx_stream.cs8" + capture = json.loads((RAW / "capture_result.json").read_text(encoding="utf-8")) + if capture.get("rf_tx_count") != 1 or capture.get("rx_sha256") != sha256(rx_path): + raise RuntimeError("Нет подтверждённой единственной RX-записи Lab064") + tx = energy.Cs8File(RAW / "tx_level_staircase.cs8") + rx = energy.Cs8File(rx_path) + location = locate_waveform(tx, rx) + background_rows = [] + for window in background_windows(): + rx_start = location["start_sample"] + window["tx_relative_start_sample"] + background_rows.append({ + **window, + "rx_start_sample": rx_start, + "metrics": _window_metrics(rx.read(rx_start, window["samples"])), + }) + background_by_label = {row["label"]: row for row in background_rows} + preceding_background = { + "L0": "INITIAL_GUARD", + "L1": "GAP_L0_L1", + "L2": "GAP_L1_L2", + "L3": "GAP_L2_L3", + } + rows = [] + for segment in metadata["segments"]: + tx_start = segment["start_sample"] + nominal_rx_start = location["start_sample"] + tx_start + fitted = energy.analyze_rx( + lab056.OffsetSource(tx, tx_start), + rx, + [nominal_rx_start], + SEGMENT_SAMPLES, + SAMPLES_PER_SYMBOL, + )["frames"][0] + actual_start = fitted["start"] + full_values = rx.read(actual_start, SEGMENT_SAMPLES) + active_reference = tx.read(tx_start + TRIM_SAMPLES, SEGMENT_SAMPLES - 2 * TRIM_SAMPLES) + active_received = rx.read(actual_start + TRIM_SAMPLES, SEGMENT_SAMPLES - 2 * TRIM_SAMPLES) + complex_gain = lab056.complex_gain(active_reference, active_received, fitted["cfo_hz"]) + matched = fitted["heldout_signal_power"] + residual = fitted["heldout_residual_power"] + background = background_by_label[preceding_background[segment["label"]]] + rows.append({ + "label": segment["label"], + "level_db": segment["level_db"], + "rx_start_sample": actual_start, + "alignment_adjustment_samples": fitted["alignment_adjustment"], + "full_iq": _window_metrics(full_values), + "background_label": background["label"], + "background": background["metrics"], + "background_window_start_sample": background["rx_start_sample"], + "matched_signal_power": matched, + "matched_signal_amplitude_rms": float(np.sqrt(matched)), + "residual_power": residual, + "residual_to_matched_amplitude_ratio": float(np.sqrt(residual / matched)), + "matched_to_residual_db": difference_db(matched, residual), + "complex_gain_h": complex_gain, + "gain_abs": fitted["gain_abs"], + "gain_power_abs_h_squared": fitted["gain_abs"] ** 2, + "cfo_hz": fitted["cfo_hz"], + "residual_spectrum": residual_spectral_diagnostics( + active_reference, active_received, fitted["cfo_hz"], complex_gain + ), + }) + comparisons = [] + for index in range(1, len(rows)): + tx_difference = metadata["segments"][index]["tx_input_difference_from_previous_db"] + rx_difference = difference_db( + rows[index]["matched_signal_power"], rows[index - 1]["matched_signal_power"] + ) + comparisons.append({ + "comparison": f"{rows[index]['label']}/{rows[index - 1]['label']}", + "tx_input_difference_db": tx_difference, + "rx_matched_power_difference_db": rx_difference, + "linearity_error_db": rx_difference - tx_difference, + "tx_amplitude_ratio": float(10 ** (tx_difference / 20)), + "rx_matched_amplitude_ratio": float(10 ** (rx_difference / 20)), + }) + total_tx_difference = difference_db( + metadata["segments"][-1]["metrics"]["power_code2"], + metadata["segments"][0]["metrics"]["power_code2"], + ) + total_rx_difference = difference_db( + rows[-1]["matched_signal_power"], rows[0]["matched_signal_power"] + ) + total_comparison = { + "comparison": "L3/L0", + "tx_digital_power_difference_db": total_tx_difference, + "rx_matched_power_difference_db": total_rx_difference, + "total_linearity_error_db": total_rx_difference - total_tx_difference, + "tx_amplitude_ratio": float(10 ** (total_tx_difference / 20)), + "rx_matched_amplitude_ratio": float(10 ** (total_rx_difference / 20)), + } + background_summary = summarize_background(background_rows) + validity = capture_validity(capture) + classification = ( + classify_linearity(rows, comparisons, background_summary) + if validity["valid"] + else { + "case": "INVALID_CAPTURE", + "invalid_reasons": validity["invalid_reasons"], + "diagnostic_metrics_only": True, + "a_b_c_classification_permitted": False, + } + ) + result = { + "lab": "Lab064", + "stage": "OFFLINE_RF_LEVEL_LINEARITY_ANALYSIS", + "packet_receiver_used": False, + "location": location, + "rows": rows, + "background_windows": background_rows, + "background_summary": background_summary, + "comparisons": comparisons, + "total_comparison": total_comparison, + "classification": classification, + "capture_valid": validity["valid"], + "capture_invalid_reasons": validity["invalid_reasons"], + "tx_underruns_maximum": capture.get("tx_counter_summary", {}).get("maximum_count"), + "tx_underruns_final": capture.get("tx_counter_summary", {}).get("final_count"), + "rx_overruns_maximum": capture.get("rx_counter_summary", {}).get("maximum_count"), + "rx_overruns_final": capture.get("rx_counter_summary", {}).get("final_count"), + "rx_clipping": any(row["full_iq"]["clipped_samples_fraction"] > 0 for row in rows), + } + save_new(OUTPUT / "rf_result.json", result) + return result + + +def preflight(*, runner=None, process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict: + """Проверить common clock и 0,1 с RX-фона, не создавая TX-команду.""" + metadata = validate_prepared() + software = json.loads((OUTPUT / "software_gate.json").read_text(encoding="utf-8")) + if not software.get("passed") or software.get("tx_sha256") != metadata["tx_sha256"]: + raise RuntimeError("Нет действующего Lab064 software gate") + save_new(RAW / "preflight_attempt.json", { + "lab": "Lab064", + "rf_tx_count": 0, + "point": asdict(POINT), + "parameters": parameters(), + "tx_sha256": metadata["tx_sha256"], + }) + actual_runner = runner or lab054.clock._default_command_runner + process_checker = process_checker or lab054.check_processes + clock_checker = clock_checker or hardware.common_clock + + def rx_only(command: list[str]): + if "-t" in command: + raise PermissionError("TX запрещён в Lab064 preflight") + return actual_runner(command) + + result = { + "lab": "Lab064", + "stage": "COMMON_CLOCK_AND_RX_ONLY_PREFLIGHT", + "passed": False, + "rf_tx_count": 0, + "stop_before_tx": True, + "point": asdict(POINT), + "parameters": parameters(), + "tx_sha256": metadata["tx_sha256"], + } + clock_started = False + try: + before = process_checker() + result["process_before"] = before.to_dict() + lab054.require_clear(before) + result["inventory"] = lab054.phy.lab045._verify_device_inventory(rx_only) + clock_started = True + result["clock"] = clock_checker( + POINT, + rx_only, + lab054.clock.TransmitGuard(), + sleep_function=sleep_function, + ) + hardware.require_clock_pass(result["clock"]) + path = RAW / "preflight_rx.cs8" + if path.exists(): + raise FileExistsError("Lab064 preflight RX уже существует") + captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES)) + stdout = captured.stdout or "" + stderr = captured.stderr or "" + result["rx_process"] = {"returncode": captured.returncode, "stdout": stdout, "stderr": stderr} + result["rx_counter_summary"] = counter_summary(stdout + stderr, "overrun") + if captured.returncode or not result["rx_counter_summary"]["clean"]: + raise RuntimeError("RX preflight: процесс или счётчик overruns не прошёл") + if path.stat().st_size != 2 * PREFLIGHT_SAMPLES: + raise RuntimeError("Неверная длина Lab064 preflight RX") + level = hardware.receive_level(path) + level.pop("symbol_energy_code2_seconds", None) + level["symbol_energy"] = "NOT_APPLICABLE_TO_BACKGROUND" + level["ac_rms_code"] = float(np.sqrt(max(0.0, level["ac_power_code2"]))) + level["unexpected_strong_signal"] = bool( + level["ac_rms_code"] >= STRONG_AC_RMS_CODE + or level["max_abs_i"] >= STRONG_COMPONENT_CODE + or level["max_abs_q"] >= STRONG_COMPONENT_CODE + ) + level["strong_signal_rule"] = ( + f"AC RMS >= {STRONG_AC_RMS_CODE} code or max |I|/|Q| >= {STRONG_COMPONENT_CODE} code" + ) + result["rx_level"] = level + result["rx_sha256"] = sha256(path) + result["rx_overruns"] = result["rx_counter_summary"]["final_count"] + if level["saturation_detected"]: + raise RuntimeError("RX clipping; STOP без изменения gain") + if level["unexpected_strong_signal"]: + raise RuntimeError("Неожиданно сильный RX-only фон; STOP без изменения gain") + result["passed"] = True + except Exception as error: + result["error"] = str(error) + raise + finally: + errors = lab054.disable_clock_outputs(rx_only) if clock_started else [] + try: + after = process_checker() + result["process_after"] = after.to_dict() + lab054.require_clear(after) + except Exception as error: + errors.append(str(error)) + result["cleanup_errors"] = errors + result["passed"] = result["passed"] and not errors + save_new(RAW / "preflight_result.json", result) + OUTPUT.mkdir(parents=True, exist_ok=True) + (OUTPUT / "preflight_result.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + ) + if not result["passed"]: + raise RuntimeError("Lab064 preflight cleanup FAIL") + return result + + +def require_preflight(metadata: dict) -> dict: + record = json.loads((RAW / "preflight_result.json").read_text(encoding="utf-8")) + if ( + record.get("passed") is not True + or record.get("rf_tx_count") != 0 + or record.get("point") != asdict(POINT) + or record.get("parameters") != parameters() + or record.get("tx_sha256") != metadata["tx_sha256"] + ): + raise PermissionError("Нет успешного RX-only preflight для этой конфигурации Lab064") + hardware.require_clock_pass(record.get("clock", {})) + level = record.get("rx_level", {}) + if ( + level.get("saturation_detected") is not False + or level.get("unexpected_strong_signal") is not False + or record.get("rx_overruns") != 0 + ): + raise PermissionError("RX-only preflight не разрешает передачу") + return record + + +def capture_once( + *, + authorization: bool = False, + runner=None, + process_starter=None, + process_checker=None, + clock_checker=None, + sleep_function=time.sleep, +) -> dict: + """Один RX-first сеанс; любая попытка расходуется до обращения к SDR.""" + if authorization is not True: + raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab064") + metadata = validate_prepared() + require_preflight(metadata) + receive_path = RAW / "rx_stream.cs8" + if receive_path.exists() or (RAW / "capture_result.json").exists(): + raise FileExistsError("Аппаратный результат Lab064 уже существует") + actual_runner = runner or lab054.clock._default_command_runner + process_starter = process_starter or lab054.clock._default_process_starter + process_checker = process_checker or lab054.check_processes + clock_checker = clock_checker or hardware.common_clock + save_new(RAW / "hardware_attempt.json", { + "lab": "Lab064", + "authorized_once": True, + "automatic_retry": False, + "point": asdict(POINT), + "parameters": parameters(), + "tx_sha256": metadata["tx_sha256"], + }) + result = { + "lab": "Lab064", + "stage": "SINGLE_RF_LEVEL_CAPTURE", + "passed": False, + "rf_tx_count": 0, + "automatic_tx_repeats": 0, + "point": asdict(POINT), + "parameters": parameters(), + "tx_sha256": metadata["tx_sha256"], + } + guard = lab054.clock.TransmitGuard() + process = None + clock_started = False + try: + before = process_checker() + result["process_before"] = before.to_dict() + lab054.require_clear(before) + result["inventory"] = lab054.phy.lab045._verify_device_inventory(actual_runner) + clock_started = True + result["clock"] = clock_checker( + POINT, actual_runner, guard, sleep_function=sleep_function + ) + hardware.require_clock_pass(result["clock"]) + process = process_starter( + transfer_command(receive_path, RX_SAMPLES), + RAW / "rx_stdout.txt", + RAW / "rx_stderr.txt", + ) + sleep_function(RX_LEAD_SECONDS) + if process.poll() is not None: + raise RuntimeError("RX завершился до TX") + before_tx = hardware.receive_level(receive_path, tail_samples=65_536) + before_tx.pop("symbol_energy_code2_seconds", None) + result["rx_level_before_tx"] = before_tx + if before_tx["saturation_detected"]: + raise RuntimeError("RX clipping до TX; RF TX = 0") + guard.register_transmit() + result["rf_tx_count"] = guard.transmit_calls + transmitted = actual_runner( + transfer_command(RAW / "tx_level_staircase.cs8", TX_SAMPLES, transmit=True) + ) + tx_stdout = transmitted.stdout or "" + tx_stderr = transmitted.stderr or "" + save_text_new(RAW / "tx_stdout.txt", tx_stdout) + save_text_new(RAW / "tx_stderr.txt", tx_stderr) + result["tx_process"] = { + "returncode": transmitted.returncode, + "stdout_file": "tx_stdout.txt", + "stderr_file": "tx_stderr.txt", + } + result["tx_counter_summary"] = counter_summary(tx_stdout + tx_stderr, "underrun") + if transmitted.returncode: + raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён") + if process.wait(RX_SECONDS + 10): + raise RuntimeError("RX завершился ошибкой; передача уже выполнена, повтор запрещён") + process.close_logs() + result["rx_samples"] = receive_path.stat().st_size // 2 + result["rx_bytes"] = receive_path.stat().st_size + result["rx_duration_seconds"] = result["rx_samples"] / SAMPLE_RATE + if result["rx_bytes"] != 2 * RX_SAMPLES: + raise RuntimeError("Повреждённая длина RX; повтор запрещён") + rx_stdout = (RAW / "rx_stdout.txt").read_text(encoding="utf-8") + rx_stderr = (RAW / "rx_stderr.txt").read_text(encoding="utf-8") + result["rx_counter_summary"] = counter_summary(rx_stdout + rx_stderr, "overrun") + result["rx_sha256"] = sha256(receive_path) + level = hardware.receive_level(receive_path) + level.pop("symbol_energy_code2_seconds", None) + result["rx_level"] = level + result["rx_clipping"] = bool(level["saturation_detected"]) + if not result["tx_counter_summary"]["clean"]: + raise RuntimeError("TX underrun или неоднозначный счётчик; результат сохранён, повтор запрещён") + if not result["rx_counter_summary"]["clean"]: + raise RuntimeError("RX overrun или неоднозначный счётчик; результат сохранён, повтор запрещён") + if result["rx_clipping"]: + raise RuntimeError("RX clipping в записи; результат сохранён, повтор запрещён") + result["passed"] = True + except Exception as error: + result["error"] = str(error) + raise + finally: + errors = [] + if process is not None: + try: + process.terminate() + except Exception as error: + errors.append(str(error)) + if clock_started: + errors.extend(lab054.disable_clock_outputs(actual_runner)) + try: + after = process_checker() + result["process_after"] = after.to_dict() + lab054.require_clear(after) + except Exception as error: + errors.append(str(error)) + result["cleanup_errors"] = errors + result["passed"] = result["passed"] and not errors + save_new(RAW / "capture_result.json", result) + OUTPUT.mkdir(parents=True, exist_ok=True) + (OUTPUT / "capture_result.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + ) + if not result["passed"]: + raise RuntimeError("Lab064 RF-сеанс завершён с ошибкой; повтор запрещён") + return result + + +def run_functional_tests() -> None: + assert SAMPLES_PER_SYMBOL == 400 + assert PRBS_SYMBOLS == 1958 + assert TX_SAMPLES == 6_000_000 + rows, _ = prepared_segments() + assert len(rows) == 4 + assert all(not row["metrics"]["digital_clipping"] for row in rows) + assert max(abs(row["tx_input_difference_from_previous_db"] - 6.0) for row in rows[1:]) < 0.1 + assert synthetic_linearity_gate()["passed"] + assert "-t" not in transfer_command("NUL", 65_536) + try: + capture_once(authorization=False) + except PermissionError: + pass + else: + raise AssertionError("RF без отдельного разрешения") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stage", + choices=("prepare", "software", "preflight", "capture", "analyze"), + required=True, + ) + parser.add_argument("--authorize-one-rf", action="store_true") + arguments = parser.parse_args() + if arguments.stage == "prepare": + result = prepare() + elif arguments.stage == "software": + result = software_gate() + elif arguments.stage == "preflight": + result = preflight() + elif arguments.stage == "capture": + result = capture_once(authorization=arguments.authorize_one_rf) + else: + result = analyze_capture() + print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)) + print("RF TX = 0") + + +if __name__ == "__main__": + main() diff --git a/tests/test_lab064_rf_level_linearity.py b/tests/test_lab064_rf_level_linearity.py new file mode 100644 index 0000000..6f2c273 --- /dev/null +++ b/tests/test_lab064_rf_level_linearity.py @@ -0,0 +1,198 @@ +"""Lab064: цифровая лестница, измерительные метрики и запрет RF без разрешения.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess + +import numpy as np +import pytest + +from experiments import lab064_rf_level_linearity as lab +from tools.hackrf_process_check import ProcessCheck, ProcessState + + +def test_fixed_operating_point_and_measurement_scope(): + parameters = lab.parameters() + assert parameters["rf_frequency_hz"] == 1_400_000_000 + assert parameters["sample_rate_hz"] == 8_000_000 + assert parameters["symbol_rate"] == 20_000 + assert parameters["samples_per_symbol"] == 400 + assert parameters["digital_carrier_hz"] == 50_000 + assert parameters["levels_db_relative_nominal"] == [-18.0, -12.0, -6.0, 0.0] + assert parameters["full_phy_packet"] is False + assert parameters["tx_vga_db"] == 10 + assert parameters["rx_lna_db"] == parameters["rx_vga_db"] == 24 + assert lab.POINT.distance_m == 1.2 + assert lab.POINT.visibility == "LOS indoor" + + +def test_waveform_has_exact_guards_segments_gaps_and_levels(): + rows, blocks = lab.prepared_segments() + assert lab.TX_SAMPLES == 6_000_000 + assert lab.TX_SAMPLES / lab.SAMPLE_RATE == 0.75 + assert all(row["samples"] == 800_000 for row in rows) + assert [row["start_sample"] for row in rows] == [800_000, 2_000_000, 3_200_000, 4_400_000] + assert all(len(blocks[row["label"]]) == 2 * lab.SEGMENT_SAMPLES for row in rows) + assert all(not row["metrics"]["digital_clipping"] for row in rows) + steps = [row["tx_input_difference_from_previous_db"] for row in rows[1:]] + assert np.allclose(steps, 6.0, atol=0.1) + + +def test_background_windows_cover_both_guards_and_all_gaps(): + windows = lab.background_windows() + assert [row["label"] for row in windows] == [ + "INITIAL_GUARD", "GAP_L0_L1", "GAP_L1_L2", "GAP_L2_L3", "FINAL_GUARD" + ] + assert all(row["samples"] == 160_000 for row in windows) + zero_ranges = [ + (0, 800_000), + (1_600_000, 2_000_000), + (2_800_000, 3_200_000), + (4_000_000, 4_400_000), + (5_200_000, 6_000_000), + ] + for row, (low, high) in zip(windows, zero_ranges): + assert low <= row["tx_relative_start_sample"] + assert row["tx_relative_start_sample"] + row["samples"] <= high + + +def test_background_stability_uses_ac_power_range(): + stable = lab.summarize_background([ + {"metrics": {"ac_power_code2": 1.0, "ac_rms_code": 1.0}}, + {"metrics": {"ac_power_code2": 1.5, "ac_rms_code": np.sqrt(1.5)}}, + ]) + changed = lab.summarize_background([ + {"metrics": {"ac_power_code2": 1.0, "ac_rms_code": 1.0}}, + {"metrics": {"ac_power_code2": 4.0, "ac_rms_code": 2.0}}, + ]) + assert stable["stable"] is True + assert changed["stable"] is False + assert changed["maximum_to_minimum_ac_power_db"] == pytest.approx(6.020599913) + + +def test_synthetic_linear_channel_recovers_digital_steps(): + result = lab.synthetic_linearity_gate() + assert result["passed"] + assert max(abs(value) for value in result["linearity_errors_db"]) < 0.02 + gains = [row["gain_abs"] for row in result["rows"]] + assert np.allclose(gains, 0.37, atol=0.001) + + +def test_counter_parser_keeps_every_intermediate_and_final_observation(): + log = "0 TX underruns\n1 TX underrun\n3 TX underruns\n3 TX underruns\n" + summary = lab.counter_summary(log, "underrun") + assert summary["selected_counts"] == [0, 1, 3, 3] + assert summary["final_count"] == 3 + assert summary["maximum_count"] == 3 + assert summary["clean"] is False + assert [row["character_offset"] for row in summary["observations"]] == sorted( + row["character_offset"] for row in summary["observations"] + ) + assert lab.counter_summary("0 RX overruns", "overrun")["clean"] is True + + +def test_intermediate_loss_cannot_be_hidden_by_final_zero(): + tx = lab.counter_summary("0 TX underruns\n1 TX underrun\n0 TX underruns", "underrun") + rx = lab.counter_summary("0 RX overruns", "overrun") + validity = lab.capture_validity({ + "passed": False, + "rx_clipping": False, + "tx_counter_summary": tx, + "rx_counter_summary": rx, + }) + assert tx["final_count"] == 0 + assert tx["maximum_count"] == 1 + assert validity["valid"] is False + assert "TX_MAXIMUM_COUNTER_NOT_ZERO" in validity["invalid_reasons"] + assert "CAPTURE_RUNNER_NOT_PASSED" in validity["invalid_reasons"] + + +def test_commands_fix_gains_and_keep_rx_separate_from_tx(): + receive = lab.transfer_command("rx.cs8", 800_000) + transmit = lab.transfer_command("tx.cs8", 100, transmit=True) + assert "-r" in receive and "-t" not in receive + assert "-t" in transmit and "-r" not in transmit + assert receive[receive.index("-f") + 1] == "1400000000" + assert receive[receive.index("-s") + 1] == "8000000" + assert receive[receive.index("-l") + 1] == receive[receive.index("-g") + 1] == "24" + assert transmit[transmit.index("-x") + 1] == "10" + assert receive[-5:] == ["-a", "0", "-p", "0", "-B"] + assert transmit[-5:] == ["-a", "0", "-p", "0", "-B"] + + +@pytest.mark.parametrize("failure", [None, "clock", "clipping", "strong", "overrun"]) +def test_preflight_never_transmits_and_fails_closed(tmp_path, monkeypatch, failure): + raw = tmp_path / "raw" + output = tmp_path / "processed" + 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"}', encoding="utf-8" + ) + monkeypatch.setattr(lab, "validate_prepared", lambda: metadata) + monkeypatch.setattr( + lab.lab054.phy.lab045, + "_verify_device_inventory", + lambda runner: {"passed": True}, + ) + + 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 (110 if failure == "strong" 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, "") + + monkeypatch.setattr(lab.lab054, "disable_clock_outputs", lambda runner: []) + 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"] + result = json.loads((raw / "preflight_result.json").read_text(encoding="utf-8")) + assert result["rf_tx_count"] == 0 + assert result["stop_before_tx"] is True + + +def test_second_preflight_is_refused_before_hardware(tmp_path, monkeypatch): + monkeypatch.setattr(lab, "RAW", tmp_path) + monkeypatch.setattr(lab, "OUTPUT", tmp_path) + (tmp_path / "preflight_attempt.json").write_text("{}", encoding="utf-8") + (tmp_path / "software_gate.json").write_text( + '{"passed": true, "tx_sha256": "TEST"}', encoding="utf-8" + ) + monkeypatch.setattr(lab, "validate_prepared", lambda: {"tx_sha256": "TEST"}) + with pytest.raises(FileExistsError): + lab.preflight(runner=lambda command: pytest.fail("hardware must not run")) + + +def test_capture_refuses_missing_authorization_before_files_or_hardware(tmp_path, monkeypatch): + monkeypatch.setattr(lab, "RAW", tmp_path) + monkeypatch.setattr(lab, "OUTPUT", tmp_path) + with pytest.raises(PermissionError): + lab.capture_once( + authorization=False, + runner=lambda command: pytest.fail("hardware must not run"), + ) + assert not (tmp_path / "hardware_attempt.json").exists()