diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index b47bd35..b62412c 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -2630,3 +2630,34 @@ Lab054. Штатный receiver и production-код не изменялись. метрики без расхождений. Lab060 не коммитировалась, push не выполнялся. Следующий этап: STOP и отдельно согласовать решение о внедрении T2 в штатный receiver; Lab061 автоматически не создавать. + +# Запись 055 + +## Дата + +11 сентября 2026 года + +## Тема + +Lab061 — OFFLINE-интеграция T2 в строгий приёмник + +## Выполнено + +Lab060 зафиксирована коммитом 92b0e0f8dd726f4420e8ef5d5e7c049357296e7c; +push не выполнялся. T2 встроен в существующий строгий PacketProcessor как явно +выбираемый режим. Legacy остался режимом по умолчанию, автоматической повторной +попытки по результату CRC нет. Коэффициенты и P3 численно совпали с Lab060. + +Полный штатный synthetic PHY-пакет 500K проверен на39 случаях в каждом режиме: +13 остаточных частот от−10 до+10Гц и три качества. На всех качествах legacy/T2 +дали CRC89,743590/94,871795%; на уровне EVM0,844032 — 9/13 и11/13. Сохранённый +TX дал16/16 в обоих режимах. Lab047 сохранил36/36, Lab054 сохранил0/16, фон +дал0 заголовков и0 пакетов. + +Интегрированное ядро T2 воспроизвело Lab056: средняя SER A/B равна +0,017756/3,501780%, срыва сопровождения нет. Проверки причинности, отсутствия +payload truth и конечности при ошибках, fade и шуме прошли. Решение — +READY_BEHIND_EXPLICIT_MODE до отдельного контролируемого RF-теста с полными +500K-пакетами. Оборудование не опрашивалось, RF TX=0. Lab061 фиксируется этим +отдельным коммитом; push выполняется пользователем отдельно. Следующий этап: +STOP; Lab062 автоматически не начинать. diff --git a/data/processed/lab061/background_result.png b/data/processed/lab061/background_result.png new file mode 100644 index 0000000..ecbf677 Binary files /dev/null and b/data/processed/lab061/background_result.png differ diff --git a/data/processed/lab061/lab047_regression.png b/data/processed/lab061/lab047_regression.png new file mode 100644 index 0000000..330d12d Binary files /dev/null and b/data/processed/lab061/lab047_regression.png differ diff --git a/data/processed/lab061/lab054_strict_result.png b/data/processed/lab061/lab054_strict_result.png new file mode 100644 index 0000000..d05e953 Binary files /dev/null and b/data/processed/lab061/lab054_strict_result.png differ diff --git a/data/processed/lab061/lab056_results.csv b/data/processed/lab061/lab056_results.csv new file mode 100644 index 0000000..aa496f5 --- /dev/null +++ b/data/processed/lab061/lab056_results.csv @@ -0,0 +1,5 @@ +label,symbol_rate,symbol_errors,symbols,symbol_error_rate,evm_rms,matched_component_power,residual_power,matched_residual_db,final_frequency_hz,large_phase_slips,loss_of_lock,bad_blocks,longest_bad_state_seconds,self_recovered,stable_lock +A1,20000,1,2816,0.0003551136363636364,0.31627242306137465,0.9760338688912713,0.09763095554074837,9.998773482861347,-0.03678470540537988,0,False,0,0.0,False,True +B1,500000,3290,101120,0.032535601265822785,0.8090614605506008,0.940782269995664,0.615817678774771,1.840369716972757,-2.3940002814874086,0,False,0,0.0,False,True +A2,20000,0,2816,0.0,0.31568433619231595,1.012966734394142,0.10094882078152588,10.014939332951363,0.0013080327716195816,0,False,0,0.0,False,True +B2,500000,3792,101120,0.0375,0.8744054425662731,0.986059800896635,0.7539264125589239,1.1657429573714726,1.9343992902116445,0,False,0,0.0,False,True diff --git a/data/processed/lab061/packet_success_by_initial_cfo.png b/data/processed/lab061/packet_success_by_initial_cfo.png new file mode 100644 index 0000000..d44845b Binary files /dev/null and b/data/processed/lab061/packet_success_by_initial_cfo.png differ diff --git a/data/processed/lab061/real_results.csv b/data/processed/lab061/real_results.csv new file mode 100644 index 0000000..318b795 --- /dev/null +++ b/data/processed/lab061/real_results.csv @@ -0,0 +1,105 @@ +dataset,frame,mode,HEADER_ACCEPTED,FEC_STARTED,FEC_PASS,CRC_STARTED,CRC_PASS,PACKET_OUTPUT,reason,initial_frequency_hz +Lab047,1,legacy,True,True,True,True,True,True,CRC_PASS,0.2033868989670703 +Lab047,2,legacy,True,True,True,True,True,True,CRC_PASS,0.012450970374705333 +Lab047,3,legacy,True,True,True,True,True,True,CRC_PASS,-0.15241868588401689 +Lab047,4,legacy,True,True,True,True,True,True,CRC_PASS,0.012628034912381616 +Lab047,5,legacy,True,True,True,True,True,True,CRC_PASS,0.28102835180050534 +Lab047,6,legacy,True,True,True,True,True,True,CRC_PASS,0.0728617397594959 +Lab047,7,legacy,True,True,True,True,True,True,CRC_PASS,0.02153239849549078 +Lab047,8,legacy,True,True,True,True,True,True,CRC_PASS,0.130332736445595 +Lab047,9,legacy,True,True,True,True,True,True,CRC_PASS,0.08586075134986197 +Lab047,10,legacy,True,True,True,True,True,True,CRC_PASS,0.019087882990447214 +Lab047,11,legacy,True,True,True,True,True,True,CRC_PASS,0.012581360419676476 +Lab047,12,legacy,True,True,True,True,True,True,CRC_PASS,0.18682072928588908 +Lab047,13,legacy,True,True,True,True,True,True,CRC_PASS,0.10358464925085982 +Lab047,14,legacy,True,True,True,True,True,True,CRC_PASS,-0.2112142577930461 +Lab047,15,legacy,True,True,True,True,True,True,CRC_PASS,0.04987013681457267 +Lab047,16,legacy,True,True,True,True,True,True,CRC_PASS,-0.018063387420586025 +Lab047,17,legacy,True,True,True,True,True,True,CRC_PASS,-0.10920375479998666 +Lab047,18,legacy,True,True,True,True,True,True,CRC_PASS,-0.023768436535983758 +Lab047,19,legacy,True,True,True,True,True,True,CRC_PASS,-0.12844207213561043 +Lab047,20,legacy,True,True,True,True,True,True,CRC_PASS,-0.021131644411390132 +Lab047,21,legacy,True,True,True,True,True,True,CRC_PASS,0.0010757873345088082 +Lab047,22,legacy,True,True,True,True,True,True,CRC_PASS,0.16827983008186687 +Lab047,23,legacy,True,True,True,True,True,True,CRC_PASS,0.030806194252629853 +Lab047,24,legacy,True,True,True,True,True,True,CRC_PASS,0.03249681818606507 +Lab047,25,legacy,True,True,True,True,True,True,CRC_PASS,-0.1189326326882962 +Lab047,26,legacy,True,True,True,True,True,True,CRC_PASS,-0.08475150493996597 +Lab047,27,legacy,True,True,True,True,True,True,CRC_PASS,0.05389239025811241 +Lab047,28,legacy,True,True,True,True,True,True,CRC_PASS,0.04289420557710872 +Lab047,29,legacy,True,True,True,True,True,True,CRC_PASS,-0.23616554750888527 +Lab047,30,legacy,True,True,True,True,True,True,CRC_PASS,-0.12603744749648454 +Lab047,31,legacy,True,True,True,True,True,True,CRC_PASS,0.0605922709725388 +Lab047,32,legacy,True,True,True,True,True,True,CRC_PASS,-0.08349857262528622 +Lab047,33,legacy,True,True,True,True,True,True,CRC_PASS,-0.15898835195874458 +Lab047,34,legacy,True,True,True,True,True,True,CRC_PASS,0.1138327859367743 +Lab047,35,legacy,True,True,True,True,True,True,CRC_PASS,-0.23280446158763507 +Lab047,36,legacy,True,True,True,True,True,True,CRC_PASS,-0.2555997057508273 +Lab047,1,t2,True,True,True,True,True,True,CRC_PASS,0.15714547272293758 +Lab047,2,t2,True,True,True,True,True,True,CRC_PASS,0.04525547988229565 +Lab047,3,t2,True,True,True,True,True,True,CRC_PASS,-0.012490675017939984 +Lab047,4,t2,True,True,True,True,True,True,CRC_PASS,0.08847136354602585 +Lab047,5,t2,True,True,True,True,True,True,CRC_PASS,0.1939244013504176 +Lab047,6,t2,True,True,True,True,True,True,CRC_PASS,0.055162738246807015 +Lab047,7,t2,True,True,True,True,True,True,CRC_PASS,0.04068198959955451 +Lab047,8,t2,True,True,True,True,True,True,CRC_PASS,0.03634649209435207 +Lab047,9,t2,True,True,True,True,True,True,CRC_PASS,-0.03437239082431202 +Lab047,10,t2,True,True,True,True,True,True,CRC_PASS,-0.004015083453945893 +Lab047,11,t2,True,True,True,True,True,True,CRC_PASS,0.08945444080931844 +Lab047,12,t2,True,True,True,True,True,True,CRC_PASS,0.20793181274469477 +Lab047,13,t2,True,True,True,True,True,True,CRC_PASS,0.07046137666433454 +Lab047,14,t2,True,True,True,True,True,True,CRC_PASS,-0.07105944506834289 +Lab047,15,t2,True,True,True,True,True,True,CRC_PASS,0.0609663723289962 +Lab047,16,t2,True,True,True,True,True,True,CRC_PASS,-0.07015498366163844 +Lab047,17,t2,True,True,True,True,True,True,CRC_PASS,-0.11928091623072502 +Lab047,18,t2,True,True,True,True,True,True,CRC_PASS,0.04019810967068637 +Lab047,19,t2,True,True,True,True,True,True,CRC_PASS,0.015930924737407013 +Lab047,20,t2,True,True,True,True,True,True,CRC_PASS,-0.008585663400001449 +Lab047,21,t2,True,True,True,True,True,True,CRC_PASS,0.03878383029001333 +Lab047,22,t2,True,True,True,True,True,True,CRC_PASS,0.09427373288664782 +Lab047,23,t2,True,True,True,True,True,True,CRC_PASS,0.0003722803565952149 +Lab047,24,t2,True,True,True,True,True,True,CRC_PASS,-0.008086735812339499 +Lab047,25,t2,True,True,True,True,True,True,CRC_PASS,0.02904818250697064 +Lab047,26,t2,True,True,True,True,True,True,CRC_PASS,-0.00973707735031404 +Lab047,27,t2,True,True,True,True,True,True,CRC_PASS,0.05145139978648825 +Lab047,28,t2,True,True,True,True,True,True,CRC_PASS,-0.07130299348886393 +Lab047,29,t2,True,True,True,True,True,True,CRC_PASS,-0.04495129868231881 +Lab047,30,t2,True,True,True,True,True,True,CRC_PASS,-0.11170196243926128 +Lab047,31,t2,True,True,True,True,True,True,CRC_PASS,-0.06762881454676493 +Lab047,32,t2,True,True,True,True,True,True,CRC_PASS,-0.05377742451538007 +Lab047,33,t2,True,True,True,True,True,True,CRC_PASS,-0.05983507118706565 +Lab047,34,t2,True,True,True,True,True,True,CRC_PASS,-0.03974346979852063 +Lab047,35,t2,True,True,True,True,True,True,CRC_PASS,-0.04857303052983457 +Lab047,36,t2,True,True,True,True,True,True,CRC_PASS,-0.0800518478779996 +Lab054,1,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,2,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,3,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,4,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,5,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,6,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,7,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,8,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,9,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,10,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,11,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,12,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,13,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,14,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,15,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,16,legacy,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,1,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,2,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,3,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,4,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,5,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,6,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,7,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,8,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,9,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,10,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,11,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,12,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,13,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,14,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,15,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, +Lab054,16,t2,False,False,False,False,False,False,PHY_HEADER_MAGIC_MISMATCH, diff --git a/data/processed/lab061/robustness_results.csv b/data/processed/lab061/robustness_results.csv new file mode 100644 index 0000000..2b7f843 --- /dev/null +++ b/data/processed/lab061/robustness_results.csv @@ -0,0 +1,6 @@ +case,finite,max_abs_frequency_hz +single,True,0.0021597810674307996 +short,True,0.0021597810674307996 +long,True,0.0021597810674307996 +amplitude_drop,True,0.0021597810674307996 +noise,True,1.6764279120281116 diff --git a/data/processed/lab061/summary.json b/data/processed/lab061/summary.json new file mode 100644 index 0000000..ad7fac7 --- /dev/null +++ b/data/processed/lab061/summary.json @@ -0,0 +1,184 @@ +{ + "lab": "Lab061", + "status": "COMPLETED", + "scope": "OFFLINE", + "rf_tx": 0, + "hardware_queries": 0, + "legacy_default": true, + "automatic_fallback": false, + "t2_parameters": { + "normalized_bandwidth": 0.0005, + "damping_factor": 0.7071067811865475, + "phase_coefficient": 0.0013324447407406753, + "frequency_coefficient": 8.882964938271169e-07, + "detector_clip_rad": 1.0471975511965976 + }, + "synthetic": { + "legacy": { + "header_pass_rate": 0.9230769230769231, + "crc_pass_rate": 0.8974358974358975 + }, + "t2": { + "header_pass_rate": 0.9487179487179487, + "crc_pass_rate": 0.9487179487179487 + } + }, + "t2_packet_success_by_initial_frequency_error": { + "-10": 0.6666666666666666, + "-5": 1.0, + "-2": 1.0, + "-1": 1.0, + "-0.5": 1.0, + "-0.25": 1.0, + "0": 1.0, + "0.25": 1.0, + "0.5": 1.0, + "1": 1.0, + "2": 1.0, + "5": 0.6666666666666666, + "10": 1.0 + }, + "real": { + "Lab047": { + "legacy": 36, + "t2": 36 + }, + "Lab054": { + "legacy": 0, + "t2": 0 + } + }, + "lab056": [ + { + "label": "A1", + "symbol_rate": 20000, + "symbol_errors": 1, + "symbols": 2816, + "symbol_error_rate": 0.0003551136363636364, + "evm_rms": 0.31627242306137465, + "matched_component_power": 0.9760338688912713, + "residual_power": 0.09763095554074837, + "matched_residual_db": 9.998773482861347, + "final_frequency_hz": -0.03678470540537988, + "large_phase_slips": 0, + "loss_of_lock": false, + "bad_blocks": 0, + "longest_bad_state_seconds": 0.0, + "self_recovered": false, + "stable_lock": true + }, + { + "label": "B1", + "symbol_rate": 500000, + "symbol_errors": 3290, + "symbols": 101120, + "symbol_error_rate": 0.032535601265822785, + "evm_rms": 0.8090614605506008, + "matched_component_power": 0.940782269995664, + "residual_power": 0.615817678774771, + "matched_residual_db": 1.840369716972757, + "final_frequency_hz": -2.3940002814874086, + "large_phase_slips": 0, + "loss_of_lock": false, + "bad_blocks": 0, + "longest_bad_state_seconds": 0.0, + "self_recovered": false, + "stable_lock": true + }, + { + "label": "A2", + "symbol_rate": 20000, + "symbol_errors": 0, + "symbols": 2816, + "symbol_error_rate": 0.0, + "evm_rms": 0.31568433619231595, + "matched_component_power": 1.012966734394142, + "residual_power": 0.10094882078152588, + "matched_residual_db": 10.014939332951363, + "final_frequency_hz": 0.0013080327716195816, + "large_phase_slips": 0, + "loss_of_lock": false, + "bad_blocks": 0, + "longest_bad_state_seconds": 0.0, + "self_recovered": false, + "stable_lock": true + }, + { + "label": "B2", + "symbol_rate": 500000, + "symbol_errors": 3792, + "symbols": 101120, + "symbol_error_rate": 0.0375, + "evm_rms": 0.8744054425662731, + "matched_component_power": 0.986059800896635, + "residual_power": 0.7539264125589239, + "matched_residual_db": 1.1657429573714726, + "final_frequency_hz": 1.9343992902116445, + "large_phase_slips": 0, + "loss_of_lock": false, + "bad_blocks": 0, + "longest_bad_state_seconds": 0.0, + "self_recovered": false, + "stable_lock": true + } + ], + "background": { + "legacy": { + "headers": 0, + "packets": 0, + "candidates": 13 + }, + "t2": { + "headers": 0, + "packets": 0, + "candidates": 13 + } + }, + "saved_tx_control": { + "legacy": { + "headers": 16, + "packets": 16 + }, + "t2": { + "headers": 16, + "packets": 16 + } + }, + "robustness": [ + { + "case": "single", + "finite": true, + "max_abs_frequency_hz": 0.0021597810674307996 + }, + { + "case": "short", + "finite": true, + "max_abs_frequency_hz": 0.0021597810674307996 + }, + { + "case": "long", + "finite": true, + "max_abs_frequency_hz": 0.0021597810674307996 + }, + { + "case": "amplitude_drop", + "finite": true, + "max_abs_frequency_hz": 0.0021597810674307996 + }, + { + "case": "noise", + "finite": true, + "max_abs_frequency_hz": 1.6764279120281116 + } + ], + "payload_truth_leak_test": "PASS: strict API and T2 have no reference payload input; causal prefix test PASS", + "production_decision": "READY_BEHIND_EXPLICIT_MODE", + "physical_packet": { + "bytes": 268, + "symbols": 5724, + "duration_seconds": 0.011532 + }, + "source_sha256": { + "data\\raw\\lab054\\rx_stream.cs8": "641886879964250c315dd399d469503808aae43fb319bef05e0bf2a78bf14585" + } +} \ No newline at end of file diff --git a/data/processed/lab061/synthetic_packet_results.csv b/data/processed/lab061/synthetic_packet_results.csv new file mode 100644 index 0000000..fe6a300 --- /dev/null +++ b/data/processed/lab061/synthetic_packet_results.csv @@ -0,0 +1,79 @@ +quality,noise_sigma,initial_frequency_error_hz,initial_phase_rad,seed,mode,measured_evm_rms,candidates,HEADER_ACCEPTED,FEC_STARTED,FEC_PASS,CRC_STARTED,CRC_PASS,PACKET_OUTPUT +GOOD,2.0,-10,0.37,61000,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-10,0.37,61000,t2,0.01982606424337177,1,True,True,True,True,True,True +GOOD,2.0,-5,0.37,61001,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-5,0.37,61001,t2,0.019746181325301454,1,True,True,True,True,True,True +GOOD,2.0,-2,0.37,61002,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-2,0.37,61002,t2,0.019842442865977007,1,True,True,True,True,True,True +GOOD,2.0,-1,0.37,61003,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-1,0.37,61003,t2,0.019853572202090563,1,True,True,True,True,True,True +GOOD,2.0,-0.5,0.37,61004,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-0.5,0.37,61004,t2,0.01981070684458435,1,True,True,True,True,True,True +GOOD,2.0,-0.25,0.37,61005,legacy,,1,True,True,True,True,True,True +GOOD,2.0,-0.25,0.37,61005,t2,0.01993850807040547,1,True,True,True,True,True,True +GOOD,2.0,0,0.37,61006,legacy,,1,True,True,True,True,True,True +GOOD,2.0,0,0.37,61006,t2,0.019706768331930264,1,True,True,True,True,True,True +GOOD,2.0,0.25,0.37,61007,legacy,,1,True,True,True,True,True,True +GOOD,2.0,0.25,0.37,61007,t2,0.019694517437587915,1,True,True,True,True,True,True +GOOD,2.0,0.5,0.37,61008,legacy,,1,True,True,True,True,True,True +GOOD,2.0,0.5,0.37,61008,t2,0.01985943013101642,1,True,True,True,True,True,True +GOOD,2.0,1,0.37,61009,legacy,,1,True,True,True,True,True,True +GOOD,2.0,1,0.37,61009,t2,0.01973023527181751,1,True,True,True,True,True,True +GOOD,2.0,2,0.37,61010,legacy,,1,True,True,True,True,True,True +GOOD,2.0,2,0.37,61010,t2,0.019705618251859375,1,True,True,True,True,True,True +GOOD,2.0,5,0.37,61011,legacy,,1,True,True,True,True,True,True +GOOD,2.0,5,0.37,61011,t2,0.01945222847829509,1,True,True,True,True,True,True +GOOD,2.0,10,0.37,61012,legacy,,1,True,True,True,True,True,True +GOOD,2.0,10,0.37,61012,t2,0.01990311396663494,1,True,True,True,True,True,True +MEDIUM,24.0,-10,0.37,61100,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-10,0.37,61100,t2,0.19171434819251312,1,True,True,True,True,True,True +MEDIUM,24.0,-5,0.37,61101,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-5,0.37,61101,t2,0.19397897593420543,1,True,True,True,True,True,True +MEDIUM,24.0,-2,0.37,61102,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-2,0.37,61102,t2,0.19306787955568316,1,True,True,True,True,True,True +MEDIUM,24.0,-1,0.37,61103,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-1,0.37,61103,t2,0.19099888656356456,1,True,True,True,True,True,True +MEDIUM,24.0,-0.5,0.37,61104,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-0.5,0.37,61104,t2,0.19250262062488108,1,True,True,True,True,True,True +MEDIUM,24.0,-0.25,0.37,61105,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,-0.25,0.37,61105,t2,0.19221599734407988,1,True,True,True,True,True,True +MEDIUM,24.0,0,0.37,61106,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,0,0.37,61106,t2,0.19404155054144742,1,True,True,True,True,True,True +MEDIUM,24.0,0.25,0.37,61107,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,0.25,0.37,61107,t2,0.19412644854555466,1,True,True,True,True,True,True +MEDIUM,24.0,0.5,0.37,61108,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,0.5,0.37,61108,t2,0.18965910158764945,1,True,True,True,True,True,True +MEDIUM,24.0,1,0.37,61109,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,1,0.37,61109,t2,0.1948138602914489,1,True,True,True,True,True,True +MEDIUM,24.0,2,0.37,61110,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,2,0.37,61110,t2,0.19457771777218613,1,True,True,True,True,True,True +MEDIUM,24.0,5,0.37,61111,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,5,0.37,61111,t2,0.19327098549387706,1,True,True,True,True,True,True +MEDIUM,24.0,10,0.37,61112,legacy,,1,True,True,True,True,True,True +MEDIUM,24.0,10,0.37,61112,t2,0.19404771262916432,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-10,0.37,61200,legacy,,10,False,False,False,False,False,False +LAB056_B_LIKE,105.0,-10,0.37,61200,t2,,10,False,False,False,False,False,False +LAB056_B_LIKE,105.0,-5,0.37,61201,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-5,0.37,61201,t2,0.8603198486837162,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-2,0.37,61202,legacy,,14,True,True,True,True,False,False +LAB056_B_LIKE,105.0,-2,0.37,61202,t2,0.8346182114574645,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-1,0.37,61203,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-1,0.37,61203,t2,0.8258615362415264,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-0.5,0.37,61204,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-0.5,0.37,61204,t2,0.8399635622236176,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-0.25,0.37,61205,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,-0.25,0.37,61205,t2,0.850324698425276,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,0,0.37,61206,legacy,,14,False,False,False,False,False,False +LAB056_B_LIKE,105.0,0,0.37,61206,t2,0.8514425010406356,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,0.25,0.37,61207,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,0.25,0.37,61207,t2,0.859691631568735,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,0.5,0.37,61208,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,0.5,0.37,61208,t2,0.8454644268223601,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,1,0.37,61209,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,1,0.37,61209,t2,0.8408531075244954,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,2,0.37,61210,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,2,0.37,61210,t2,0.8303200529343647,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,5,0.37,61211,legacy,,23,False,False,False,False,False,False +LAB056_B_LIKE,105.0,5,0.37,61211,t2,,23,False,False,False,False,False,False +LAB056_B_LIKE,105.0,10,0.37,61212,legacy,,1,True,True,True,True,True,True +LAB056_B_LIKE,105.0,10,0.37,61212,t2,0.8454912251538359,1,True,True,True,True,True,True diff --git a/data/processed/lab061/synthetic_t2_frequency.png b/data/processed/lab061/synthetic_t2_frequency.png new file mode 100644 index 0000000..ccab118 Binary files /dev/null and b/data/processed/lab061/synthetic_t2_frequency.png differ diff --git a/data/processed/lab061/synthetic_t2_phase.png b/data/processed/lab061/synthetic_t2_phase.png new file mode 100644 index 0000000..1a3b3cb Binary files /dev/null and b/data/processed/lab061/synthetic_t2_phase.png differ diff --git a/data/processed/lab061/trajectories.npz b/data/processed/lab061/trajectories.npz new file mode 100644 index 0000000..a8bf3e1 Binary files /dev/null and b/data/processed/lab061/trajectories.npz differ diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index beb7d9e..6a59e13 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -547,6 +547,26 @@ [JSON](../data/processed/lab060/summary.json), [real CSV](../data/processed/lab060/real_results.csv), [synthetic CSV](../data/processed/lab060/synthetic_results.csv). -- **STOP:** исходные IQ не изменились, Lab060 не коммитировалась, push не - выполнялся, Lab061 не создана. Следующий этап — отдельно согласовать решение - о внедрении T2 в штатный receiver. +- **Фиксация:** Lab060 зафиксирована коммитом + `92b0e0f8dd726f4420e8ef5d5e7c049357296e7c`; push не выполнялся. + +## Lab061 — T2 в строгом приёмнике + +- **Запись 055; только offline, RF TX=0.** T2 встроен в существующий + `PacketProcessor` как явный режим; legacy остался значением по умолчанию. + Автоматического CRC-fallback нет, строгие header/FEC/CRC правила не менялись. +- **Полный synthetic PHY 500K:** 39 случаев на режим, CFO от −10 до +10 Гц, + три качества. Header legacy/T2 92,307692/94,871795%, CRC + 89,743590/94,871795%. На EVM0,844032 packets 9/13 против11/13. +- **Реальные контроли:** Lab047 legacy/T2 36/36; Lab054 0/16; saved TX 16/16; + background 0 headers и0 packets. Исторический Lab054 `NOT_A` сохранён. +- **Lab056:** средняя SER A/B 0,017756/3,501780%, потери lock нет. Реальная + широкополосная ошибка около3,5% остаётся физическим ограничением. +- **Решение:** `READY_BEHIND_EXPLICIT_MODE` до контролируемого RF-теста с + полными 500K-пакетами. [План](lab061_current_state.md), + [отчёт](lab061_offline_result.md), + [JSON](../data/processed/lab061/summary.json), + [real CSV](../data/processed/lab061/real_results.csv), + [synthetic CSV](../data/processed/lab061/synthetic_packet_results.csv). +- **STOP:** Lab061 фиксируется этим отдельным коммитом; push выполняется + пользователем отдельно, Lab062 не создана. diff --git a/docs/lab061_current_state.md b/docs/lab061_current_state.md new file mode 100644 index 0000000..7daae6e --- /dev/null +++ b/docs/lab061_current_state.md @@ -0,0 +1,52 @@ +# Lab061 — план интеграции T2 в строгий приёмник + +Lab061 выполняется только офлайн. `RF TX = 0`; HackRF не опрашивается. + +## Фактический путь приёмника + +`lab053_streaming_receiver.StreamingReceiver` выполняет независимый поиск +кандидатов и передаёт заранее выбранному `PacketProcessor` IQ-окно. Внутри +`PacketProcessor` последовательно работают RRC-фильтр, временная +синхронизация, оценка несущей, извлечение мягких символов, строгий разбор PHY +header, Viterbi FEC, parser и CRC. + +T2 добавляется в этот существующий путь после RRC и timing. Начальное состояние +P3 вычисляется по квадрату всей 1280-символьной преамбулы. После преамбулы T2 +видит только текущий символ, собственное BPSK-решение и предыдущее состояние. +Header, payload, CRC и будущие символы ему недоступны. + +Среднее DC в T2 считается только до последнего raw-отсчёта, который через +причинную импульсную характеристику RRC способен повлиять на последнюю точку +P3. Отсчёты header и payload не входят и в эту подготовительную оценку. + +Режим задаётся до запуска как `carrier_tracking_mode="legacy"` или `"t2"`. +Значение по умолчанию — `legacy`. Повторной попытки после CRC FAIL нет. + +## Зафиксированная математика + +При нормированной полосе `Bn=0.0005` и затухании `zeta=1/sqrt(2)`: + +`theta = Bn / (zeta + 1/(4*zeta))` + +`alpha = 4*zeta*theta / (1 + 2*zeta*theta + theta^2)` + +`beta = 4*theta^2 / (1 + 2*zeta*theta + theta^2)` + +Получаются `alpha=0.0013324447407406753` и +`beta=8.882964938271169e-7` на один символ. Частотное состояние внутри петли +хранится в радианах на символ; для отчёта оно переводится в герцы как +`omega*Rs/(2*pi)`. Коэффициенты Lab060 не перенастраиваются. + +## Критерии + +- чистый полный штатный PHY-пакет 500 ksymbol/s проходит поиск, header, FEC и CRC; +- сравнение legacy/T2 выполняется при заранее выбранном режиме; +- Lab047 остаётся 36/36 в обоих режимах; +- Lab054 остаётся историческим `NOT_A, 0/16`, если строгий путь его не восстановит; +- background даёт ноль header и packet; +- изменение диагностической reference payload не меняет решения или траекторию T2; +- алгоритм остаётся конечным при ошибках символов, провале амплитуды и шуме. + +После завершения и независимой проверки Lab061 фиксируется отдельным коммитом +по разрешению пользователя. Push выполняется пользователем отдельно. Lab062 не +создаётся автоматически. diff --git a/docs/lab061_offline_result.md b/docs/lab061_offline_result.md new file mode 100644 index 0000000..9193453 --- /dev/null +++ b/docs/lab061_offline_result.md @@ -0,0 +1,92 @@ +# Lab061 — T2 в строгом приёмнике + +## Результат простыми словами + +T2 перенесён из диагностической Lab060 в тот же строгий путь, который ищет +пакет, синхронизируется, разбирает PHY header, запускает FEC и принимает пакет +только после CRC PASS. Режим включается явно; обычный запуск по-прежнему +использует legacy. Скрытого повторного декодирования нет. + +На сохранённом Lab047 оба режима приняли 36 из 36 пакетов. На Lab054 оба дали +0 из 16: исторический результат `NOT_A` не изменён. Чистая сохранённая TX-форма +дала 16/16 в обоих режимах, фон — ноль допустимых заголовков и пакетов. + +## Полный синтетический PHY-пакет 500K + +Штатный генератор сформировал преамбулу, повторённый header, FEC-кодированное +тело и CRC. Проверено 39 реализаций для каждого режима: 13 начальных ошибок +частоты от −10 до +10 Гц, фиксированная начальная фаза и три уровня шума. +Условное среднее измеренное EVM уровня `LAB056_B_LIKE` равно 0,844032 по 11 +полным T2-траекториям из 13; два случая, остановившиеся раньше полного +извлечения, в это среднее не входят. + +| Режим | Header | CRC и packet | +|---|---:|---:| +| legacy | 92,307692% | 89,743590% | +| T2 | 94,871795% | 94,871795% | + +На качестве около Lab056 B legacy принял 9/13 пакетов, T2 — 11/13. На двух +лучших уровнях оба режима приняли 26/26. По всем трём уровням доля T2 для CFO +−10/−5/−2/−1/−0,5/−0,25/0/+0,25/+0,5/+1/+2/+5/+10 Гц составила +66,67/100/100/100/100/100/100/100/100/100/100/66,67/100%. + +Текущий штатный пакет с 256 байтами payload содержит 268 packet bytes и 5724 +символа; его сформированная длительность — 11,532 мс. Указанные ранее 204,8 мс +относятся к длинному диагностическому B-burst Lab056 из 102400 символов, а не к +этому штатному пакету. На всём B-burst T2 также проверен: срыва сопровождения +нет. + +## Сохранённые данные + +Средняя ошибка символов Lab056 после интегрированного ядра T2: + +- A: `(1 + 0) / (2816 + 2816) = 0,017756%`; +- B: `(3290 + 3792) / (101120 + 101120) = 3,501780%`. + +B1/B2 дали 3,253560% и 3,750000%; A1/A2 — 0,035511% и 0%. Все четыре +траектории сохранили устойчивый внутренний lock без больших фазовых скачков. + +Для 36 пакетов Lab047 начальная P3-оценка лежала от −0,119281 до +0,207932 Гц. +Это далеко внутри проверенного диапазона; прежнее ограничение 20 ksymbol/s при +искусственной ошибке до ±10 Гц сохраняется. Отдельная настройка 20K не вводилась. + +Lab054 дал в обоих режимах 0 header, 0 FEC, 0 CRC и 0 packet из 16 проверенных +исторических кандидатов. T2 не переопределяет этот отрицательный результат. + +## Независимость и отказоустойчивость + +T2 не принимает reference payload. Тест причинности меняет последний будущий +символ и подтверждает полное равенство всех предыдущих решений и состояний. +Отдельный тест меняет все raw-отсчёты после причинной границы P3 и подтверждает +равенство начальных частоты и комплексного коэффициента P3 с допуском `1e-8`; +остаток порядка `4e-9 Гц` возникает из-за округления FFT-свёртки. +Изменение диагностической reference меняет только последующее сравнение; путь +приёма и его RX bits от неё не зависят. + +Одиночная ошибка, серии из 10 и 80 ошибок, провал амплитуды и шумовой участок +не создали NaN/Inf. Максимальное наблюдаемое частотное состояние составило +1,676428 Гц; дополнительный state clamp не вводился. На реальном фоне оба +режима проверили 13 кандидатов и не приняли ни одного заголовка или пакета. + +## Решение + +`PRODUCTION DECISION = READY_BEHIND_EXPLICIT_MODE`. + +T2 численно совпадает с Lab060, полезен на полном строгом пути и не создаёт +регрессии Lab047, saved TX или background. Его рано делать режимом по умолчанию: +Lab054 остаётся 0/16, а контролируемого RF-сеанса с полноценными 500K-пакетами и +заранее выбранным T2 ещё не было. + +После исправления программного сопровождения реальный широкий B-сигнал Lab056 +всё равно имеет около 3,5% ошибочных символов при хорошем диагностическом +выравнивании. Следующая физическая задача — проверить энергетику и качество +широкополосного канала на полноценном пакете в одном заранее разрешённом +контролируемом опыте. Это отдельная лаборатория и здесь не выполнялось. + +[Сводка JSON](../data/processed/lab061/summary.json), +[реальные результаты](../data/processed/lab061/real_results.csv), +[синтетические пакеты](../data/processed/lab061/synthetic_packet_results.csv), +[траектории](../data/processed/lab061/trajectories.npz). + +`RF TX = 0`. Lab061 фиксируется этим отдельным коммитом. Push выполняется +пользователем отдельно. Lab062 не создавалась. diff --git a/experiments/lab053_packet_processing.py b/experiments/lab053_packet_processing.py index 2597d5b..47f0605 100644 --- a/experiments/lab053_packet_processing.py +++ b/experiments/lab053_packet_processing.py @@ -25,6 +25,7 @@ from scipy.signal import oaconvolve from experiments import lab052_candidate_detection as detection from experiments.lab053_viterbi import viterbi_decode_soft, warmup +from protocol import bpsk_carrier_tracking as carrier_tracking previous = detection.previous physical = previous.lab045 @@ -83,9 +84,11 @@ def measured(name): @lru_cache(maxsize=16) -def rotation(length: int) -> np.ndarray: +def rotation(length: int, sample_rate_hz: int = SAMPLE_RATE) -> np.ndarray: indexes = np.arange(length) - values = np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE).astype(np.complex64) + values = np.exp( + -2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / sample_rate_hz + ).astype(np.complex64) values.flags.writeable = False return values @@ -97,24 +100,46 @@ class PacketProcessor: Полное тело по-прежнему запрашивается только после валидного PHY header. """ - def __init__(self, *, viterbi=viterbi_decode_soft): + def __init__( + self, + *, + viterbi=viterbi_decode_soft, + carrier_tracking_mode: str = "legacy", + sample_rate_hz: int = SAMPLE_RATE, + symbol_rate: int = SYMBOL_RATE, + ): + if carrier_tracking_mode not in ("legacy", "t2"): + raise ValueError("carrier_tracking_mode должен быть legacy или t2") + if type(sample_rate_hz) is not int or type(symbol_rate) is not int: + raise TypeError("Fs и Rs должны быть целыми") + if sample_rate_hz <= 0 or symbol_rate <= 0 or sample_rate_hz % symbol_rate: + raise ValueError("Fs должен быть положительным целым кратным Rs") + self.carrier_tracking_mode = carrier_tracking_mode + self.sample_rate_hz = sample_rate_hz + self.symbol_rate = symbol_rate + self.samples_per_symbol = sample_rate_hz // symbol_rate self.startup = warmup() if viterbi is viterbi_decode_soft else {"backend": "CUSTOM", "warmup_seconds": 0} - constants = {"SAMPLES_PER_SYMBOL": SAMPLES_PER_SYMBOL, "SYMBOL_RATE": SYMBOL_RATE} + constants = { + "SAMPLES_PER_SYMBOL": self.samples_per_symbol, + "SYMBOL_RATE": self.symbol_rate, + } scale = bind(physical.lab044._scale_and_carrier, **constants) self.bits = physical.lab044.preamble_bits().copy() waveform = scale(previous.iq.radio.bpsk_modulate(self.bits)) self.reference = physical.lab044.signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform)) self.taps = bind(lab043._matched_filter_taps, **constants)() self.expected = previous.iq.radio.bpsk_modulate(self.bits) - self.kernel = np.zeros((len(self.bits) - 1) * SAMPLES_PER_SYMBOL + 1, np.complex64) - self.kernel[::SAMPLES_PER_SYMBOL] = np.conj(self.expected) + self.kernel = np.zeros( + (len(self.bits) - 1) * self.samples_per_symbol + 1, np.complex64 + ) + self.kernel[::self.samples_per_symbol] = np.conj(self.expected) self.kernel = self.kernel[::-1].copy() for values in (self.bits, self.reference, self.taps, self.expected, self.kernel): values.flags.writeable = False slow_offsets = bind(lab043._timing_offsets, **constants) slow_timing = bind(previous.ORIGINAL_TIMING, _timing_offsets=slow_offsets, **constants) self.timing = measured("timing")(bind(previous.fast_preamble_timing, - lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=SAMPLES_PER_SYMBOL), + lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=self.samples_per_symbol), timing_kernel=lambda unused: self.kernel, ORIGINAL_TIMING=slow_timing)) estimate = measured("cfo_estimation")(bind(lab043._estimate_cfo_gain, **constants)) self.carrier = measured("phase_polarity")(bind(lab043._evaluate_symbols, @@ -142,7 +167,20 @@ class PacketProcessor: @measured("frequency_shift") def shift(self, segment): - return (segment - np.mean(segment)) * rotation(len(segment)) + if self.carrier_tracking_mode == "legacy": + dc_reference = segment + else: + # Последний raw-отсчёт, способный попасть через причинный RRC в + # последнюю P3-точку преамбулы. Header в оценку DC не попадает. + fixed_prefix = ( + 16 * self.samples_per_symbol + + self.taps.size + + (carrier_tracking.PREAMBLE_SYMBOLS - 1) * self.samples_per_symbol + ) + dc_reference = segment[:min(len(segment), fixed_prefix)] + return (segment - np.mean(dc_reference)) * rotation( + len(segment), self.sample_rate_hz + ) @measured("matched_rrc_filtering") def filter(self, values): @@ -150,22 +188,55 @@ class PacketProcessor: @measured("soft_symbol_extraction") def normalize(self, matched, nominal, timing, count, carrier): - symbols = matched[nominal + timing:nominal + timing + count * SAMPLES_PER_SYMBOL:SAMPLES_PER_SYMBOL].astype(np.complex128) + symbols = matched[ + nominal + timing: + nominal + timing + count * self.samples_per_symbol: + self.samples_per_symbol + ].astype(np.complex128) if symbols.size != count: return None - symbol_time = (np.arange(count, dtype=np.float64) + .5) / SYMBOL_RATE - normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time) - normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"]) - return normalized.real + if self.carrier_tracking_mode == "legacy": + symbol_time = ( + np.arange(count, dtype=np.float64) + 0.5 + ) / self.symbol_rate + normalized = symbols * np.exp( + -2j * math.pi * carrier["cfo_hz"] * symbol_time + ) + normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"]) + return normalized.real + + if count < carrier_tracking.PREAMBLE_SYMBOLS: + return None + estimate = carrier_tracking.estimate_p3( + symbols[:carrier_tracking.PREAMBLE_SYMBOLS], + self.expected, + self.symbol_rate, + ) + carrier.update({ + "cfo_hz": estimate.frequency_hz, + "abs_h": float(abs(estimate.gain)), + "phase_rad": float(np.angle(estimate.gain)), + "polarity": "resolved from P3 full preamble", + }) + result = carrier_tracking.track_t2( + symbols, + self.symbol_rate, + estimate, + start_index=carrier_tracking.PREAMBLE_SYMBOLS, + ) + context = CURRENT.get() + if context is not None: + context.cache.setdefault("carrier_tracking_traces", []).append(result) + return result.corrected.real @measured("extract_total") def extract(self, samples, frame_start, symbol_count): - length = (symbol_count + 42) * SAMPLES_PER_SYMBOL + length = (symbol_count + 42) * self.samples_per_symbol if frame_start < 0 or frame_start + length > samples.size: return previous.iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW") segment = samples[frame_start:frame_start + length] matched = self.filter(self.shift(segment)) - nominal = 16 * SAMPLES_PER_SYMBOL + self.taps.size - 1 + nominal = 16 * self.samples_per_symbol + self.taps.size - 1 timing = self.timing(matched, nominal, self.expected) carrier = self.carrier(matched, nominal, timing, self.bits, len(self.bits)) if carrier["abs_h"] == 0: @@ -174,7 +245,7 @@ class PacketProcessor: if values is None: return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS") return previous.iq.SoftWindow(True, values, int(timing), - int((frame_start + nominal + timing) % SAMPLES_PER_SYMBOL), nominal, carrier, None) + int((frame_start + nominal + timing) % self.samples_per_symbol), nominal, carrier, None) def decode(self, samples, candidate, number=1): context = PacketMeasure() @@ -189,7 +260,11 @@ class PacketProcessor: "thread_cpu_seconds": time.thread_time() - cpu_started, "stages": dict(context.seconds), "exclusive_stages": dict(context.exclusive_seconds), "calls": dict(context.calls), "coarse": candidate.sample_offset, - "refined": outcome.refined_start_sample} + "refined": outcome.refined_start_sample, + "carrier_tracking_mode": self.carrier_tracking_mode, + "carrier_tracking_traces": tuple( + context.cache.get("carrier_tracking_traces", ()) + )} if outcome.frame is not None: row.update(timing=outcome.frame.timing_offset_samples, cfo_hz=outcome.frame.estimated_cfo_hz, phase_rad=outcome.frame.estimated_phase_rad, polarity=outcome.frame.bpsk_polarity) @@ -201,6 +276,7 @@ class PacketProcessor: def run_functional_tests() -> None: processor = PacketProcessor() assert len(processor.bits) == 1280 and len(processor.taps) == 161 + assert processor.carrier_tracking_mode == "legacy" assert not processor.reference.flags.writeable diff --git a/experiments/lab061_strict_receiver_t2.py b/experiments/lab061_strict_receiver_t2.py new file mode 100644 index 0000000..7a32fb3 --- /dev/null +++ b/experiments/lab061_strict_receiver_t2.py @@ -0,0 +1,260 @@ +"""Lab061: офлайн-интеграция T2 в существующий строгий приёмник.""" + +from __future__ import annotations + +import csv +import hashlib +import json +import math +from pathlib import Path +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import matplotlib.pyplot as plt +import numpy as np + +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab049_bpsk_symbol_rate as lab049 +from experiments import lab052_candidate_detection as detection +from experiments import lab053_packet_processing as packet +from experiments import lab053_streaming_receiver as streaming +from experiments import lab058_abab_symbol_quality as lab058 +from experiments import lab059_preamble_carrier_limits as lab059 +from experiments import lab060_decision_directed_carrier_tracking as lab060 +from protocol import bpsk_carrier_tracking as tracking + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "data/processed/lab061" +FREQUENCIES = (-10, -5, -2, -1, -0.5, -0.25, 0, 0.25, 0.5, 1, 2, 5, 10) +QUALITIES = {"GOOD": 2.0, "MEDIUM": 24.0, "LAB056_B_LIKE": 105.0} + + +def write_csv(path: Path, rows: list[dict]) -> None: + if not rows: + return + columns = list(rows[0]) + with path.open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, columns) + writer.writeheader() + writer.writerows(rows) + + +def full_packet() -> tuple[np.ndarray, bytes, np.ndarray]: + payload = bytes(range(256)) + plan = lab045.build_transmission_plan(payload) + raw = np.frombuffer( + lab049.build_parameterized_tx_cs8(plan, lab049.PhyMode(500_000)), np.int8 + ) + values = raw[::2].astype(np.float32) + 1j * raw[1::2].astype(np.float32) + expected = lab049.radio.bpsk_modulate(plan.frame_bits) + return values.astype(np.complex64), payload, expected + + +def stream_decode(values: np.ndarray, mode: str) -> tuple[list, streaming.StreamingReceiver, list[dict]]: + observed: list[dict] = [] + receiver = streaming.StreamingReceiver( + processor=packet.PacketProcessor(carrier_tracking_mode=mode), + packet_observer=lambda row, outcome: observed.append({ + "reason": outcome.reason, + "accepted": outcome.accepted, + "header": bool(outcome.phy_header_pass), + "fec": bool(outcome.fec_pass), + "crc": outcome.crc == "PASS", + "traces": row["carrier_tracking_traces"], + }), + ) + output = [] + for start in range(0, len(values), receiver.block_samples): + output.extend(receiver.push_iq_block(values[start:start + receiver.block_samples])) + output.extend(receiver.finish()) + return output, receiver, observed + + +def synthetic_study() -> tuple[list[dict], dict[str, np.ndarray]]: + clean, payload, expected = full_packet() + indexes = np.arange(len(clean)) + rows: list[dict] = [] + representative: dict[str, np.ndarray] = {} + for quality_number, (quality, noise_sigma) in enumerate(QUALITIES.items()): + for frequency in FREQUENCIES: + seed = 61_000 + quality_number * 100 + FREQUENCIES.index(frequency) + random_generator = np.random.default_rng(seed) + noise = random_generator.normal(0, noise_sigma, len(clean)) + 1j * random_generator.normal(0, noise_sigma, len(clean)) + impaired = clean * np.exp(1j * (0.37 + 2 * np.pi * frequency * indexes / 8_000_000)) + noise + values = np.concatenate((np.zeros(20_000), impaired, np.zeros(20_000))).astype(np.complex64) + for mode in ("legacy", "t2"): + output, receiver, observed = stream_decode(values, mode) + header = any(row["header"] for row in observed) + fec = any(row["fec"] for row in observed) + crc = any(row["crc"] for row in observed) + traces = [trace for item in observed for trace in item["traces"]] + full_trace = max(traces, key=lambda item: len(item.corrected)) if traces else None + measured_evm = None + if full_trace is not None and len(full_trace.corrected) == len(expected): + selected = slice(1280, None) + gain = np.mean(full_trace.corrected[selected] * expected[selected]) + residual = full_trace.corrected[selected] - gain * expected[selected] + measured_evm = float(np.sqrt(np.mean(abs(residual)**2) / abs(gain)**2)) + rows.append({ + "quality": quality, "noise_sigma": noise_sigma, + "initial_frequency_error_hz": frequency, "initial_phase_rad": 0.37, + "seed": seed, "mode": mode, "measured_evm_rms": measured_evm, + "candidates": receiver.candidate_count, + "HEADER_ACCEPTED": header, "FEC_STARTED": header, + "FEC_PASS": fec, "CRC_STARTED": fec, "CRC_PASS": crc, + "PACKET_OUTPUT": len(output) == 1 and output[0].payload == payload, + }) + if mode == "t2" and quality == "LAB056_B_LIKE" and frequency == 10 and observed: + if traces: + trace = max(traces, key=lambda item: len(item.phase_rad)) + representative = { + "phase_rad": trace.phase_rad, + "frequency_hz": trace.frequency_hz, + "detector_error_rad": trace.detector_error_rad, + } + return rows, representative + + +def decode_known_candidates() -> list[dict]: + lab054_items, lab047_items, lab054_reader, lab047_reader = lab059._packet_sources() + rows: list[dict] = [] + for dataset, symbol_rate, items, reader, key in ( + ("Lab047", 20_000, lab047_items, lab047_reader, "candidate_offset"), + ("Lab054", 500_000, lab054_items, lab054_reader, "coarse_start"), + ): + for mode in ("legacy", "t2"): + processor = packet.PacketProcessor(carrier_tracking_mode=mode, symbol_rate=symbol_rate) + for number, item in enumerate(items, 1): + start = int(item[key]) + low = max(0, start - 4_000) + length = 2_400_000 if symbol_rate == 20_000 else 120_000 + values = reader.read(low, min(length, reader.size - low)) + candidate = detection.offline.CoarseCandidate(start - low, 1.0) + outcome, detail = processor.decode(values, candidate, number) + rows.append({ + "dataset": dataset, "frame": number, "mode": mode, + "HEADER_ACCEPTED": outcome.phy_header_pass, + "FEC_STARTED": outcome.phy_header_pass, + "FEC_PASS": outcome.fec_pass, + "CRC_STARTED": outcome.fec_pass, + "CRC_PASS": outcome.crc == "PASS", "PACKET_OUTPUT": outcome.accepted, + "reason": outcome.reason, "initial_frequency_hz": detail.get("cfo_hz"), + }) + return rows + + +def lab056_study() -> tuple[list[dict], dict[str, np.ndarray]]: + rows, trajectories = [], {} + for item in lab060._lab056_data(): + source = item["estimate"] + estimate = tracking.P3CarrierEstimate( + source.frequency_hz, source.gain, source.phase_fit_rmse_rad, source.mean_block_coherence + ) + result = tracking.track_t2(item["symbols"], item["symbol_rate"], estimate) + compatible = lab060.TrackResult( + tracker="T2", corrected=result.corrected, + hard_decisions=result.hard_decisions, phase_rad=result.phase_rad, + frequency_hz=result.frequency_hz, + detector_error_rad=result.detector_error_rad, + parameters=lab060.loop_parameters("T2", 0.0005), + initial_frequency_hz=estimate.frequency_hz, + initial_phase_rad=float(np.angle(estimate.gain)), + ) + metrics, blocks = lab060.evaluate_tracking(compatible, item["expected"], item["symbol_rate"]) + rows.append({"label": item["label"], "symbol_rate": item["symbol_rate"], **{k: v for k, v in metrics.items() if k != "receiver_lock"}, "stable_lock": metrics["receiver_lock"]["stable_lock"]}) + trajectories[f"{item['label']}_phase_rad"] = result.phase_rad + trajectories[f"{item['label']}_frequency_hz"] = result.frequency_hz + return rows, trajectories + + +def background_study() -> dict: + source = json.loads((ROOT / "data/processed/lab060/summary.json").read_text(encoding="utf-8")) + background = source["background"][0] + start, count = int(background["rx_start"]), int(background["samples"]) + reader = lab058.Cs8File(ROOT / "data/raw/lab054/rx_stream.cs8") + values = reader.read(start, count) + results = {} + for mode in ("legacy", "t2"): + output, receiver, observed = stream_decode(values, mode) + results[mode] = {"headers": sum(row["header"] for row in observed), "packets": len(output), "candidates": receiver.candidate_count} + return results + + +def saved_tx_control() -> dict: + reader = lab058.Cs8File(ROOT / "data/raw/lab054/tx_stream.cs8") + values = reader.read(0, reader.size) + results = {} + for mode in ("legacy", "t2"): + output, receiver, observed = stream_decode(values, mode) + results[mode] = {"headers": sum(row["header"] for row in observed), "packets": len(output)} + return results + + +def robustness() -> list[dict]: + symbols, expected, unused = lab060.clean_rrc_symbols(500_000) + estimate = tracking.estimate_p3(symbols[:1280], expected[:1280], 500_000) + cases = {} + for name, selected in (("single", slice(2000, 2001)), ("short", slice(2500, 2510)), ("long", slice(3000, 3080))): + damaged = symbols.astype(np.complex128) + damaged[selected] *= -1 + cases[name] = damaged + fade = symbols.astype(np.complex128); fade[3500:3700] *= 0.02; cases["amplitude_drop"] = fade + noise = symbols.astype(np.complex128); random_generator = np.random.default_rng(61999); noise[4000:4300] = random_generator.normal(size=300) + 1j*random_generator.normal(size=300); cases["noise"] = noise + rows = [] + for name, values in cases.items(): + result = tracking.track_t2(values, 500_000, estimate) + rows.append({"case": name, "finite": bool(np.all(np.isfinite(result.corrected)) and np.all(np.isfinite(result.frequency_hz))), "max_abs_frequency_hz": float(np.max(abs(result.frequency_hz)))}) + return rows + + +def plots(synthetic: list[dict], representative: dict, real: list[dict], background: dict) -> None: + plt.figure(); plt.plot(np.arange(len(representative["phase_rad"]))/500_000, representative["phase_rad"]); plt.xlabel("Время, с"); plt.ylabel("Фаза T2, рад"); plt.tight_layout(); plt.savefig(OUTPUT/"synthetic_t2_phase.png", dpi=160); plt.close() + plt.figure(); plt.plot(np.arange(len(representative["frequency_hz"]))/500_000, representative["frequency_hz"]); plt.xlabel("Время, с"); plt.ylabel("Частотное состояние, Гц"); plt.tight_layout(); plt.savefig(OUTPUT/"synthetic_t2_frequency.png", dpi=160); plt.close() + for mode in ("legacy", "t2"): + values = [np.mean([r["PACKET_OUTPUT"] for r in synthetic if r["mode"] == mode and r["initial_frequency_error_hz"] == f]) for f in FREQUENCIES] + plt.plot(FREQUENCIES, values, marker="o", label=mode) + plt.xlabel("Начальная ошибка частоты, Гц"); plt.ylabel("Доля пакетов"); plt.legend(); plt.tight_layout(); plt.savefig(OUTPUT/"packet_success_by_initial_cfo.png", dpi=160); plt.close() + for dataset, filename in (("Lab047", "lab047_regression.png"), ("Lab054", "lab054_strict_result.png")): + values = [sum(r["PACKET_OUTPUT"] for r in real if r["dataset"] == dataset and r["mode"] == mode) for mode in ("legacy", "t2")] + plt.figure(); plt.bar(("legacy", "t2"), values); plt.ylabel("CRC PASS пакеты"); plt.tight_layout(); plt.savefig(OUTPUT/filename, dpi=160); plt.close() + plt.figure(); plt.bar(("legacy headers", "legacy packets", "T2 headers", "T2 packets"), (background["legacy"]["headers"], background["legacy"]["packets"], background["t2"]["headers"], background["t2"]["packets"])); plt.ylabel("Количество"); plt.tight_layout(); plt.savefig(OUTPUT/"background_result.png", dpi=160); plt.close() + + +def main() -> None: + OUTPUT.mkdir(parents=True, exist_ok=True) + synthetic, representative = synthetic_study() + real = decode_known_candidates() + lab056, lab056_trajectories = lab056_study() + background = background_study() + tx_control = saved_tx_control() + robust = robustness() + write_csv(OUTPUT/"synthetic_packet_results.csv", synthetic) + write_csv(OUTPUT/"real_results.csv", real) + write_csv(OUTPUT/"lab056_results.csv", lab056) + write_csv(OUTPUT/"robustness_results.csv", robust) + np.savez_compressed(OUTPUT/"trajectories.npz", **representative, **lab056_trajectories) + plots(synthetic, representative, real, background) + by_mode = {mode: {"header_pass_rate": float(np.mean([r["HEADER_ACCEPTED"] for r in synthetic if r["mode"] == mode])), "crc_pass_rate": float(np.mean([r["CRC_PASS"] for r in synthetic if r["mode"] == mode]))} for mode in ("legacy", "t2")} + by_frequency = {str(f): float(np.mean([r["PACKET_OUTPUT"] for r in synthetic if r["mode"] == "t2" and r["initial_frequency_error_hz"] == f])) for f in FREQUENCIES} + summary = { + "lab": "Lab061", "status": "COMPLETED", "scope": "OFFLINE", "rf_tx": 0, + "hardware_queries": 0, "legacy_default": True, "automatic_fallback": False, + "t2_parameters": vars(tracking.t2_parameters()), + "synthetic": by_mode, "t2_packet_success_by_initial_frequency_error": by_frequency, + "real": {dataset: {mode: sum(r["PACKET_OUTPUT"] for r in real if r["dataset"] == dataset and r["mode"] == mode) for mode in ("legacy", "t2")} for dataset in ("Lab047", "Lab054")}, + "lab056": lab056, "background": background, "saved_tx_control": tx_control, + "robustness": robust, + "payload_truth_leak_test": "PASS: strict API and T2 have no reference payload input; causal prefix test PASS", + "production_decision": "READY_BEHIND_EXPLICIT_MODE", + "physical_packet": {"bytes": 268, "symbols": 5724, "duration_seconds": len(full_packet()[0])/8_000_000}, + "source_sha256": {str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in (ROOT/"data/raw/lab054/rx_stream.cs8",)}, + } + (OUTPUT/"summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, default=lambda value: value.item()), encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2, default=lambda value: value.item())) + print("RF TX = 0") + + +if __name__ == "__main__": + main() diff --git a/protocol/bpsk_carrier_tracking.py b/protocol/bpsk_carrier_tracking.py new file mode 100644 index 0000000..2d06b08 --- /dev/null +++ b/protocol/bpsk_carrier_tracking.py @@ -0,0 +1,230 @@ +"""Причинное сопровождение несущей BPSK после известной преамбулы. + +Модуль не разбирает заголовок, полезную нагрузку, FEC или CRC. Начальное +состояние P3 оценивается только по переданным в функцию символам преамбулы. +После неё T2 получает текущий комплексный символ, собственное решение и своё +предыдущее состояние. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import numpy as np + + +PREAMBLE_SYMBOLS = 1280 +P3_BLOCK_SYMBOLS = 20 +NORMALIZED_LOOP_BANDWIDTH = 0.0005 +DAMPING_FACTOR = 1.0 / math.sqrt(2.0) +DETECTOR_CLIP_RAD = math.pi / 3.0 + + +@dataclass(frozen=True) +class P3CarrierEstimate: + """Начальное состояние в физических единицах при времени символа 0.""" + + frequency_hz: float + gain: complex + phase_fit_rmse_rad: float + squared_coherence: float + + +@dataclass(frozen=True) +class T2Parameters: + """Коэффициенты петли на один символ.""" + + normalized_bandwidth: float + damping_factor: float + phase_coefficient: float + frequency_coefficient: float + detector_clip_rad: float + + +@dataclass(frozen=True) +class T2TrackingResult: + """Применённое состояние и решения для одного последовательного прохода.""" + + corrected: np.ndarray + hard_decisions: np.ndarray + phase_rad: np.ndarray + frequency_hz: np.ndarray + detector_error_rad: np.ndarray + initial_estimate: P3CarrierEstimate + parameters: T2Parameters + + +def t2_parameters( + normalized_bandwidth: float = NORMALIZED_LOOP_BANDWIDTH, + damping_factor: float = DAMPING_FACTOR, +) -> T2Parameters: + """Перевести нормированную полосу в коэффициенты Lab060 на символ.""" + + if not np.isfinite(normalized_bandwidth) or normalized_bandwidth <= 0.0: + raise ValueError("Нормированная полоса должна быть положительной") + if not np.isfinite(damping_factor) or damping_factor <= 0.0: + raise ValueError("Коэффициент затухания должен быть положительным") + theta = normalized_bandwidth / ( + damping_factor + 0.25 / damping_factor + ) + denominator = 1.0 + 2.0 * damping_factor * theta + theta * theta + return T2Parameters( + normalized_bandwidth=float(normalized_bandwidth), + damping_factor=float(damping_factor), + phase_coefficient=float(4.0 * damping_factor * theta / denominator), + frequency_coefficient=float(4.0 * theta * theta / denominator), + detector_clip_rad=DETECTOR_CLIP_RAD, + ) + + +def _huber_phase_line( + block_values: np.ndarray, + block_times_seconds: np.ndarray, +) -> tuple[float, float, float, float]: + """Повторить зафиксированную в P3 устойчивую прямую фазы.""" + + values = np.asarray(block_values, dtype=np.complex128) + times = np.asarray(block_times_seconds, dtype=np.float64) + if values.ndim != 1 or times.ndim != 1 or len(values) != len(times) or len(values) < 3: + raise ValueError("Недостаточно фазовых блоков") + phases = np.unwrap(np.angle(values)) + base_weights = np.maximum(np.abs(values), 1e-12) + center_time = float(np.sum(base_weights * times) / np.sum(base_weights)) + centered = times - center_time + design = np.column_stack((np.ones(len(times)), centered)) + weights = base_weights.copy() + coefficients = np.zeros(2, dtype=np.float64) + for _iteration in range(12): + root = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) + updated, *_unused = np.linalg.lstsq( + design * root[:, None], phases * root, rcond=None + ) + residual = phases - design @ updated + median = float(np.median(residual)) + scale = 1.4826 * float(np.median(np.abs(residual - median))) + 1e-9 + limit = 1.345 * scale + robust = np.ones_like(residual) + outside = np.abs(residual) > limit + robust[outside] = limit / np.abs(residual[outside]) + weights = base_weights * robust + if np.allclose(updated, coefficients, rtol=0.0, atol=1e-12): + coefficients = updated + break + coefficients = updated + residual = phases - design @ coefficients + rmse = float(np.sqrt(np.sum(base_weights * residual**2) / np.sum(base_weights))) + phase_at_zero = float(coefficients[0] - coefficients[1] * center_time) + coherence = float(abs(np.sum(values)) / max(float(np.sum(abs(values))), 1e-30)) + return phase_at_zero, float(coefficients[1]), rmse, coherence + + +def estimate_p3( + preamble_symbols: np.ndarray, + known_preamble_symbols: np.ndarray, + symbol_rate: float, +) -> P3CarrierEstimate: + """Оценить P3 только по полной 1280-символьной преамбуле.""" + + received = np.asarray(preamble_symbols, dtype=np.complex128) + known = np.asarray(known_preamble_symbols, dtype=np.complex128) + if received.shape != (PREAMBLE_SYMBOLS,) or known.shape != received.shape: + raise ValueError("P3 требуется ровно 1280 принятых и известных символов") + if not np.isfinite(symbol_rate) or symbol_rate <= 0.0: + raise ValueError("Скорость символов должна быть положительной") + if not np.all(np.isfinite(received)) or not np.all(np.isfinite(known)): + raise ValueError("P3 не принимает NaN или Inf") + + indexes = np.arange(PREAMBLE_SYMBOLS, dtype=np.int64) + block_count = PREAMBLE_SYMBOLS // P3_BLOCK_SYMBOLS + block_values = np.mean( + (received**2).reshape(block_count, P3_BLOCK_SYMBOLS), axis=1 + ) + block_times = np.mean( + ((indexes + 0.5) / symbol_rate).reshape(block_count, P3_BLOCK_SYMBOLS), + axis=1, + ) + _phase0, squared_slope, rmse, coherence = _huber_phase_line( + block_values, block_times + ) + frequency_hz = float(squared_slope / (4.0 * math.pi)) + times = (indexes + 0.5) / symbol_rate + gain = complex(np.mean( + received + * np.conj(known) + * np.exp(-2j * math.pi * frequency_hz * times) + )) + if not np.isfinite(gain.real) or not np.isfinite(gain.imag) or abs(gain) == 0.0: + raise ValueError("P3 получил нулевой или неконечный коэффициент") + return P3CarrierEstimate(frequency_hz, gain, rmse, coherence) + + +def normalized_phase_detector( + corrected_symbol: complex, + detector_clip_rad: float = DETECTOR_CLIP_RAD, +) -> tuple[int, float]: + """Вернуть собственное решение BPSK и ограниченную фазовую ошибку.""" + + value = complex(corrected_symbol) + if not np.isfinite(value.real) or not np.isfinite(value.imag): + raise ValueError("Фазовый детектор не принимает NaN или Inf") + hard = 1 if value.real >= 0.0 else -1 + error = math.atan2(value.imag * hard, value.real * hard) + return hard, float(np.clip(error, -detector_clip_rad, detector_clip_rad)) + + +def track_t2( + symbols: np.ndarray, + symbol_rate: float, + initial_estimate: P3CarrierEstimate, + *, + start_index: int = PREAMBLE_SYMBOLS, + parameters: T2Parameters | None = None, +) -> T2TrackingResult: + """Последовательно применить T2; будущие символы не используются.""" + + values = np.asarray(symbols, dtype=np.complex128) + if values.ndim != 1 or not len(values) or not np.all(np.isfinite(values)): + raise ValueError("T2 требуется конечная одномерная последовательность") + if not np.isfinite(symbol_rate) or symbol_rate <= 0.0: + raise ValueError("Скорость символов должна быть положительной") + if not 0 <= start_index <= len(values): + raise ValueError("Некорректная граница начала сопровождения") + if abs(initial_estimate.gain) == 0.0: + raise ValueError("Начальный коэффициент P3 не может быть нулевым") + selected = t2_parameters() if parameters is None else parameters + + corrected = np.empty(len(values), dtype=np.complex128) + decisions = np.empty(len(values), dtype=np.int8) + phases = np.empty(len(values), dtype=np.float64) + frequencies = np.empty(len(values), dtype=np.float64) + detector = np.empty(len(values), dtype=np.float64) + omega = 2.0 * math.pi * initial_estimate.frequency_hz / symbol_rate + phase0 = float(np.angle(initial_estimate.gain)) + phase = phase0 + omega * (start_index + 0.5) + amplitude = abs(initial_estimate.gain) + + for index, symbol in enumerate(values): + current_phase = phase0 + omega * (index + 0.5) if index < start_index else phase + value = symbol * np.exp(-1j * current_phase) / amplitude + hard, error = normalized_phase_detector(value, selected.detector_clip_rad) + corrected[index] = value + decisions[index] = hard + phases[index] = current_phase + frequencies[index] = omega * symbol_rate / (2.0 * math.pi) + detector[index] = error + if index >= start_index: + phase = phase + omega + selected.phase_coefficient * error + omega = omega + selected.frequency_coefficient * error + + for array in (corrected, decisions, phases, frequencies, detector): + array.flags.writeable = False + return T2TrackingResult( + corrected=corrected, + hard_decisions=decisions, + phase_rad=phases, + frequency_hz=frequencies, + detector_error_rad=detector, + initial_estimate=initial_estimate, + parameters=selected, + ) diff --git a/tests/test_lab061_strict_receiver_t2.py b/tests/test_lab061_strict_receiver_t2.py new file mode 100644 index 0000000..f19d72c --- /dev/null +++ b/tests/test_lab061_strict_receiver_t2.py @@ -0,0 +1,130 @@ +"""Быстрые проверки интеграции T2 в строгий приёмник Lab061.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab049_bpsk_symbol_rate as lab049 +from experiments import lab053_packet_processing as packet +from experiments import lab053_streaming_receiver as streaming +from experiments import lab059_preamble_carrier_limits as lab059 +from experiments import lab060_decision_directed_carrier_tracking as lab060 +from protocol import bpsk_carrier_tracking as tracking + + +def full_packet(payload: bytes = bytes(range(256))) -> np.ndarray: + plan = lab045.build_transmission_plan(payload) + mode = lab049.PhyMode(500_000) + raw = np.frombuffer(lab049.build_parameterized_tx_cs8(plan, mode), np.int8) + return raw[::2].astype(np.float32) + 1j * raw[1::2].astype(np.float32) + + +def receive(values: np.ndarray, mode: str): + receiver = streaming.StreamingReceiver( + processor=packet.PacketProcessor(carrier_tracking_mode=mode) + ) + output = [] + for start in range(0, len(values), receiver.block_samples): + output.extend(receiver.push_iq_block(values[start:start + receiver.block_samples])) + output.extend(receiver.finish()) + return output, receiver + + +def impaired(cfo_hz: float, seed: int = 61001) -> np.ndarray: + clean = full_packet() + indexes = np.arange(len(clean)) + signal = clean * np.exp(1j * (0.37 + 2 * np.pi * cfo_hz * indexes / 8_000_000)) + random_generator = np.random.default_rng(seed) + noise = random_generator.normal(0, 2.0, len(clean)) + 1j * random_generator.normal(0, 2.0, len(clean)) + return np.concatenate((np.zeros(20_000, np.complex64), signal + noise, np.zeros(20_000, np.complex64))).astype(np.complex64) + + +def test_t2_coefficients_and_frequency_units(): + parameters = tracking.t2_parameters() + assert parameters.phase_coefficient == pytest.approx(0.0013324447407406753) + assert parameters.frequency_coefficient == pytest.approx(8.882964938271169e-7) + + +def test_p3_and_t2_are_numerically_equivalent_to_lab060(): + symbols, expected, metadata = lab060.clean_rrc_symbols(500_000) + p3 = lab059.estimate_from_burst(symbols, 500_000, "P3") + production = tracking.P3CarrierEstimate( + p3.frequency_hz, p3.gain, p3.phase_fit_rmse_rad, p3.mean_block_coherence + ) + actual = tracking.track_t2(symbols, 500_000, production) + reference = lab060.track_symbols(symbols, 500_000, p3.frequency_hz, p3.gain, "T2", lab060.loop_parameters("T2", 0.0005)) + assert metadata["symbols"] == len(symbols) + assert np.allclose(actual.corrected, reference.corrected, rtol=0, atol=3e-15) + assert np.array_equal(actual.hard_decisions, reference.hard_decisions) + + +def test_default_is_explicit_legacy_and_invalid_mode_is_rejected(): + assert packet.PacketProcessor().carrier_tracking_mode == "legacy" + with pytest.raises(ValueError): + packet.PacketProcessor(carrier_tracking_mode="automatic") + + +@pytest.mark.parametrize("mode", ["legacy", "t2"]) +def test_clean_full_500k_packet_passes_header_fec_crc(mode): + output, receiver = receive(impaired(0.0), mode) + assert len(output) == receiver.packet_count == 1 + assert output[0].payload == bytes(range(256)) + + +@pytest.mark.parametrize("cfo_hz", [-10.0, -2.0, 2.0, 10.0]) +def test_t2_full_packet_with_positive_and_negative_residual_frequency(cfo_hz): + output, receiver = receive(impaired(cfo_hz), "t2") + assert len(output) == receiver.packet_count == 1 + + +def test_t2_is_causal_and_payload_truth_is_not_an_input(): + symbols, expected, unused = lab060.clean_rrc_symbols(500_000) + p3 = tracking.estimate_p3(symbols[:1280], expected[:1280], 500_000) + first = tracking.track_t2(symbols, 500_000, p3) + changed = symbols.copy() + changed[-1] *= -1 + second = tracking.track_t2(changed, 500_000, p3) + assert np.array_equal(first.hard_decisions[:-1], second.hard_decisions[:-1]) + assert np.array_equal(first.phase_rad[:-1], second.phase_rad[:-1]) + + +def test_p3_frontend_does_not_read_raw_header_or_payload(): + processor = packet.PacketProcessor(carrier_tracking_mode="t2") + original = full_packet().astype(np.complex128) + changed = original.copy() + boundary = 16 * processor.samples_per_symbol + processor.taps.size + 1279 * processor.samples_per_symbol + changed[boundary:] += 10.0 + 7.0j + def p3(values): + matched = processor.filter(processor.shift(values)) + nominal = 16 * processor.samples_per_symbol + processor.taps.size - 1 + received = matched[nominal:nominal + 1280 * processor.samples_per_symbol:processor.samples_per_symbol] + return tracking.estimate_p3(received, processor.expected, processor.symbol_rate) + first, second = p3(original), p3(changed) + assert first.frequency_hz == pytest.approx(second.frequency_hz, abs=1e-8) + assert first.gain == pytest.approx(second.gain, abs=1e-8) + + +def test_error_bursts_fade_and_noise_remain_finite(): + symbols, expected, unused = lab060.clean_rrc_symbols(500_000) + p3 = tracking.estimate_p3(symbols[:1280], expected[:1280], 500_000) + damaged = symbols.astype(np.complex128) + damaged[2000] *= -1 + damaged[2600:2610] *= -1 + damaged[3200:3280] *= -1 + damaged[4000:4200] *= 0.02 + random_generator = np.random.default_rng(61002) + damaged[4500:4700] = random_generator.normal(size=200) + 1j * random_generator.normal(size=200) + result = tracking.track_t2(damaged, 500_000, p3) + assert np.all(np.isfinite(result.corrected)) + assert np.all(np.isfinite(result.frequency_hz)) + assert np.max(np.abs(result.frequency_hz)) < 100.0 + + +def test_background_produces_no_header_or_packet_and_seed_is_deterministic(): + one = np.random.default_rng(61003).normal(size=180_000).astype(np.float32).astype(np.complex64) + two = np.random.default_rng(61003).normal(size=180_000).astype(np.float32).astype(np.complex64) + assert np.array_equal(one, two) + output, receiver = receive(one, "t2") + assert output == [] and receiver.packet_count == 0