Lab064: measure OTA level linearity at TX VGA 10 dB

This commit is contained in:
LittleSam129
2026-09-15 12:03:48 +03:00
parent 4437917465
commit cff226e0bf
12 changed files with 2831 additions and 0 deletions

View File

@@ -2780,3 +2780,84 @@ DC стабилен. Сильная аналоговая перегрузка п
выполнялся. Следующий этап: STOP и отдельно согласовать исследование уровня RX, усилений, выполнялся. Следующий этап: STOP и отдельно согласовать исследование уровня RX, усилений,
геометрии и возможной аналоговой компрессии или слабого полезного сигнала; Lab064 автоматически геометрии и возможной аналоговой компрессии или слабого полезного сигнала; 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 остаётся равным нулю.

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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"
]
}

View File

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

View File

@@ -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"
}

View File

@@ -624,3 +624,42 @@
[локальное сопоставление CSV](../data/processed/lab063/local_alignment.csv). [локальное сопоставление CSV](../data/processed/lab063/local_alignment.csv).
- **STOP:** Lab063 не коммитировалась, push не выполнялся, Lab064 не создана. Следующий шаг - **STOP:** Lab063 не коммитировалась, push не выполнялся, Lab064 не создана. Следующий шаг
требует отдельного согласования; RF TX=0. требует отдельного согласования; 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.

View File

@@ -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` прошёл.

216
docs/lab064_rf_result.md Normal file
View File

@@ -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 не создана.

120
docs/lab064_rx_preflight.md Normal file
View File

@@ -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 автоматически не начинается.

View File

@@ -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"(?<![\w.+-])(\d+)\s+(?:(rx|tx)\s+)?(overruns?|underruns?)\b",
re.IGNORECASE,
)
return [
{
"count": int(match.group(1)),
"role": (match.group(2) or "UNKNOWN").upper(),
"kind": match.group(3).lower().rstrip("s"),
"text": match.group(0),
"character_offset": match.start(),
}
for match in pattern.finditer(log)
]
def counter_summary(log: str, expected_kind: str) -> 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"(?<![\w.+-])\d+\s+(?:(?:rx|tx)\s+)?(?:overruns?|underruns?)\b",
"",
log,
flags=re.IGNORECASE,
),
re.IGNORECASE,
)
return {
"expected_kind": expected_kind,
"observations": observations,
"selected_counts": [row["count"] for row in selected],
"final_count": selected[-1]["count"] if selected else None,
"maximum_count": max((row["count"] for row in selected), default=None),
"unparsed_loss_text": bool(unparsed),
"clean": bool(selected) and not unparsed and all(row["count"] == 0 for row in observations),
}
def synthetic_linearity_gate() -> 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()

View File

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