diff --git a/data/processed/lab043_diagnostics/capture_triplets.csv b/data/processed/lab043_diagnostics/capture_triplets.csv new file mode 100644 index 0000000..c2080b4 --- /dev/null +++ b/data/processed/lab043_diagnostics/capture_triplets.csv @@ -0,0 +1,17 @@ +source,triplet,count,bit_errors,ber,mean_i,mean_q,median_i,median_q,std_i,std_q,mean_amplitude,mean_phase_error_deg,mean_distance_to_ideal,decision_cross_fraction,raw_mean_i,raw_mean_q,fitted_gain_i,fitted_gain_q +lab043_prbs11_s_20260819_134831,000,618,26,0.042071197411003236,-0.383010551932381,0.02764562694974822,-0.3665048003985516,0.029452696638587655,0.23064885120898107,0.31028090419369436,0.49896947141893955,-7.8956708685012496,0.6944003153099989,0.042071197411003236,-0.383010551932381,0.02764562694974822,1.0,0.0 +lab043_prbs11_s_20260819_134831,001,626,8,0.012779552715654952,-0.4912906363346837,0.018636267614070716,-0.4764967027338469,0.026144887267764125,0.24269006389944242,0.30667428490022475,0.586409195973437,-5.494314642423132,0.5998968119242502,0.012779552715654952,-0.4912906363346837,0.018636267614070716,1.0,0.0 +lab043_prbs11_s_20260819_134831,010,625,0,0.0,1.6246814910527565,0.021540864870758254,1.6232689830218876,0.024312702015861856,0.24557868253488369,0.33923505401863113,1.6592362427400138,0.18194257569663422,0.7093629876140989,0.0,1.6246814910527565,0.021540864870758254,1.0,0.0 +lab043_prbs11_s_20260819_134831,011,623,0,0.0,1.507888339613931,0.027362614438028364,1.499955117966784,0.02131274610695385,0.2323442921990874,0.31797487033373506,1.540917677274962,0.5236092842361051,0.599722415704199,0.0,1.507888339613931,0.027362614438028364,1.0,0.0 +lab043_prbs11_s_20260819_134831,100,626,0,0.0,-0.8627782853937753,-0.2999262964156233,-0.8705450786019031,-0.30817634309654046,0.2368292067405127,0.31394777549671404,0.9578798163510112,17.143319552998086,0.4677097487796899,0.0,-0.8627782853937753,-0.2999262964156233,1.0,0.0 +lab043_prbs11_s_20260819_134831,101,622,0,0.0,-0.9853183896444535,-0.290203005512205,-0.9745048898520654,-0.30930749839713845,0.22008895048116267,0.31972987963350635,1.069647529275547,14.80231929336532,0.4353899021808402,0.0,-0.9853183896444535,-0.290203005512205,1.0,0.0 +lab043_prbs11_s_20260819_134831,110,624,0,0.0,1.1217639844594565,-0.3035772157066439,1.1310492864236945,-0.29087932020583385,0.27164792642941604,0.33523556914475766,1.2139708847640265,-15.592910444294075,0.48678001136541427,0.0,1.1217639844594565,-0.3035772157066439,1.0,0.0 +lab043_prbs11_s_20260819_134831,111,634,0,0.0,1.0177963357632442,-0.2857254059392602,0.9787287867259802,-0.30392693232926526,0.28513896636571545,0.3301477780970683,1.1124010421708548,-16.389369324426834,0.4743368758003392,0.0,1.0177963357632442,-0.2857254059392602,1.0,0.0 +lab043_prbs11_s_20260819_150001,000,618,22,0.03559870550161812,-0.46422854876395536,-0.1034304947322828,-0.450617540491747,-0.08384365846908659,0.2970812100005612,0.2764371342120699,0.5552113454902189,4.975489960217222,0.6374530970020094,0.03559870550161812,-0.46422854876395536,-0.1034304947322828,1.0,0.0 +lab043_prbs11_s_20260819_150001,001,626,5,0.007987220447284345,-0.6086025098841648,-0.09102967026250966,-0.593154030437537,-0.07621843397472354,0.2969390097675917,0.28426567583901835,0.679352068931168,3.5118628297753047,0.527946124467199,0.007987220447284345,-0.6086025098841648,-0.09102967026250966,1.0,0.0 +lab043_prbs11_s_20260819_150001,010,625,0,0.0,1.543937892189543,-0.0866919401911572,1.5401352267056545,-0.08580489080415578,0.296974183803192,0.29581951510651777,1.5765627581653052,-4.197559508608589,0.6421798718369047,0.0,1.543937892189543,-0.0866919401911572,1.0,0.0 +lab043_prbs11_s_20260819_150001,011,623,0,0.0,1.3844334140042036,-0.08532571149356706,1.3744620638063159,-0.08924519261103618,0.26685328732688507,0.2844049814454168,1.4183308010699238,-4.696533674284714,0.5093696634103756,0.0,1.3844334140042036,-0.08532571149356706,1.0,0.0 +lab043_prbs11_s_20260819_150001,100,626,0,0.0,-0.9614469278519722,-0.2996346771243095,-0.9507264001164575,-0.2877579356042255,0.2858999743426548,0.2780537590113996,1.0354745742870346,15.35184388016101,0.45571081471161806,0.0,-0.9614469278519722,-0.2996346771243095,1.0,0.0 +lab043_prbs11_s_20260819_150001,101,622,0,0.0,-1.1028098132631565,-0.2865285029053816,-1.0971437080692867,-0.28672431820918576,0.2777319065206098,0.2773121159933899,1.164923379747912,12.850190789658376,0.43898765209393537,0.0,-1.1028098132631565,-0.2865285029053816,1.0,0.0 +lab043_prbs11_s_20260819_150001,110,624,0,0.0,1.0473064096865614,-0.30446287638539515,1.0398500615588286,-0.30154972207819863,0.31726665642731866,0.31265493779409337,1.145036505689775,-17.636126072712916,0.48953383056372857,0.0,1.0473064096865614,-0.30446287638539515,1.0,0.0 +lab043_prbs11_s_20260819_150001,111,634,0,0.0,0.8838047295662679,-0.2988072133215322,0.8598290716063504,-0.31001570377948856,0.30258146260956686,0.2954051290073809,0.9925463363278624,-20.654607590716907,0.47726234856311894,0.0,0.8838047295662679,-0.2988072133215322,1.0,0.0 diff --git a/data/processed/lab043_diagnostics/dc_iq_metrics.csv b/data/processed/lab043_diagnostics/dc_iq_metrics.csv new file mode 100644 index 0000000..b3d6141 --- /dev/null +++ b/data/processed/lab043_diagnostics/dc_iq_metrics.csv @@ -0,0 +1,3 @@ +capture,raw_bpsk_mean_i,raw_bpsk_mean_q,coarse_before_rrc_mean_i,coarse_before_rrc_mean_q,matched_mean_i,matched_mean_q,positive_center_i,positive_center_q,negative_center_i,negative_center_q,symbol_dc_i,symbol_dc_q,positive_distance_from_zero,negative_distance_from_zero,positive_decision_margin_i,negative_decision_margin_i,residual_std_i,residual_std_q,iq_std_ratio,iq_correlation,ellipse_axis_ratio,cluster_phase_separation_deg,negative_is_closer_to_boundary +lab043_prbs11_s_20260819_134831,-0.000737236196254884,-0.0009993250003590963,-0.0035866184049641804,-0.0020547927693326117,-0.039057998243808414,-0.022376535029459025,1.316916651234259,-0.135555308255011,-0.6813036323127601,-0.1363165494566035,0.3178065094607494,-0.13593592885580724,1.3238748837764716,0.6948070531149747,1.316916651234259,0.6813036323127601,0.3528092065580048,0.3591613286621408,0.9823140143517195,0.4949299078201867,1.720973769005295,162.80857605270015,True +lab043_prbs11_s_20260819_150001,-0.0006657749906019511,-0.0009936757629662666,-0.003953464860272102,0.0015903730102091952,-0.0430650724829096,0.017323925058185086,1.2138202803903815,-0.1941434021582646,-0.7849789639235105,-0.1952336579265019,0.21442065823343548,-0.19468853004238323,1.2292482799209319,0.8088931666108884,1.2138202803903815,0.7849789639235105,0.3918420026998634,0.3061660076990989,1.2798350987578189,0.5683356891913195,2.0067883608694936,156.94608113337333,True diff --git a/data/processed/lab043_diagnostics/ideal_channel_triplets.csv b/data/processed/lab043_diagnostics/ideal_channel_triplets.csv new file mode 100644 index 0000000..b5307d2 --- /dev/null +++ b/data/processed/lab043_diagnostics/ideal_channel_triplets.csv @@ -0,0 +1,49 @@ +source,triplet,count,bit_errors,ber,mean_i,mean_q,median_i,median_q,std_i,std_q,mean_amplitude,mean_phase_error_deg,mean_distance_to_ideal,decision_cross_fraction,raw_mean_i,raw_mean_q,fitted_gain_i,fitted_gain_q,sample_phase,marker_score +без искажений,000,618,0,0.0,-1.0002931436799982,-3.231550594878434e-17,-1.000199150848986,-2.319552256062034e-17,0.010357093519487945,3.265577683360462e-16,1.0002931436799982,1.865020053989257e-15,0.008257521038058883,0.0,-0.7002312139878651,-1.8677900020981957e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,001,626,0,0.0,-0.9999796510040612,-5.090250830548143e-17,-0.999960846145106,-5.0648676345923463e-17,0.010322664900678936,3.2693891435801005e-16,0.9999796510040612,2.917838992654595e-15,0.008200634420287638,0.0,-0.7000117609620851,-3.1690521028700267e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,010,625,0,0.0,0.9996656101027557,-5.665573859282219e-18,0.9997287758415592,-1.976950863675177e-17,0.010214140548392219,3.215255041375122e-16,0.9996656101027557,-3.183082645139798e-16,0.008056123282006363,0.0,0.69979192416429,-7.907369434377148e-18,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,011,623,0,0.0,1.0000428840677498,-4.3416350411530316e-17,1.0000526981049074,-5.442655424073825e-17,0.010198050251563825,3.245666613153081e-16,1.0000428840677498,-2.4913640504576667e-15,0.00804844359529067,0.0,0.7000560257510929,-3.4335382214621765e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,100,626,0,0.0,-1.000125130099672,5.735360631441449e-17,-1.00010630034017,5.265207942418089e-17,0.010300836630339004,3.281107650887046e-16,1.000125130099672,-3.2889352554807928e-15,0.00818089388859184,0.0,-0.7001136001123113,4.409214806236349e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,101,622,0,0.0,-0.9996849230913982,1.0023266103027503e-17,-0.9996385783577552,2.3580954795874935e-17,0.010177697810059332,3.0848704318872783e-16,0.9996849230913982,-5.597698391419076e-16,0.00806103637993291,0.0,-0.6998054437585894,1.0957943474359258e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,110,624,0,0.0,1.0001671317135128,4.321013147730515e-17,1.0000061919503653,4.9544714803699065e-17,0.01016122023731124,3.3618993042126056e-16,1.0001671317135128,2.4804251249806343e-15,0.008053662299073073,0.0,0.700143002334285,2.6304918033938118e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +без искажений,111,634,0,0.0,1.0000674445201205,-1.0076005050440176e-17,0.999980779418755,-2.2358492137958885e-18,0.010336983321136445,3.3089869748917757e-16,1.0000674445201205,-5.646438653305923e-16,0.008248570840346733,0.0,0.7000732187064663,-1.0996370252882711e-17,0.700026005788434,-3.942638773738879e-18,0,0.9999345849172596 +только SRO -3.5 ppm,000,618,0,0.0,-1.0007823562340121,-3.574668630819571e-17,-1.000878892829398,-4.3096068599418156e-17,0.012318977500386755,3.2273716564340147e-16,1.0007823562340121,2.0613001594081967e-15,0.00989708790253621,0.0,-0.7002980588855288,-3.1863576567390073e-17,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,001,626,0,0.0,-0.9756825166001841,-3.5137944174706555e-17,-0.9764796404233334,-3.399764234555552e-17,0.015474967835911041,3.3301062428152723e-16,0.9756825166001841,2.074517712909942e-15,0.02479755480588827,0.0,-0.6827344309254474,-3.12658141332338e-17,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,010,625,0,0.0,0.9991752242954434,-6.4629008792038566e-18,0.9990077802745452,-1.004275690703834e-17,0.012183624311424834,3.188338440579737e-16,0.9991752242954434,-3.629750966993994e-16,0.009808842813789232,0.0,0.6991734673397825,2.3163924670052718e-18,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,011,623,0,0.0,1.024346330590892,-2.2166208744888554e-17,1.0235396176905007,-3.032025209860568e-17,0.014902847080977328,3.4078402976628906e-16,1.024346330590892,-1.2355426929143958e-15,0.024724855270462344,0.0,0.7167869641894236,-8.499724167082658e-18,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,100,626,0,0.0,-1.0244438779762572,6.353925085997965e-17,-1.0233996653515027,6.721164976147591e-17,0.015214285979592818,3.4300871746429124e-16,1.0244438779762572,-3.5503879489943633e-15,0.024813236661930232,0.0,-0.7168552230313137,3.744986774399351e-17,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,101,622,0,0.0,-0.9991704855700663,3.781276373700251e-18,-0.9991312032539466,5.950964397953672e-18,0.01221593063466391,3.186349063739632e-16,0.9991704855700663,-2.0953093788571495e-16,0.009819473338403427,0.0,-0.6991701514138348,-4.19282840214353e-18,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,110,624,0,0.0,0.9758868931118758,3.370098110123651e-17,0.9765977242877031,4.857969605056626e-17,0.01525005133245707,3.241803988698192e-16,0.9758868931118758,1.9777270721475476e-15,0.024539569248432168,0.0,0.6828774435130773,3.0261697199855787e-17,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только SRO -3.5 ppm,111,634,0,0.0,1.0005460550201486,-7.442020366725188e-18,1.0003785050455694,-1.1930610215603022e-17,0.012193950170514516,3.3444527734491764e-16,1.0005460550201486,-3.931010026179579e-16,0.0097585801127184,0.0,0.7001327069682511,1.6406356047352882e-18,0.6997506046377366,6.844456406296741e-18,0,0.9999346349570075 +только остаточная CFO +0.5 Гц,000,618,0,0.0,-1.0005405145730908,0.004549961910286652,-1.00491272276492,-0.004322898425968361,0.025524201284612764,0.2280583742146684,1.0264751200596467,-0.26129439402136373,0.20125941061500233,0.0,-0.5650484892946761,-0.3828705455373927,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,001,626,0,0.0,-0.9998959428731508,-0.0008962621558364577,-1.0041557696715198,8.636341190708949e-05,0.02547526333332098,0.22948062607379993,1.0261534184142616,0.05252364078134037,0.20211366611691822,0.0,-0.5625875851895938,-0.3856884185785349,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,010,625,0,0.0,0.9996889183875104,0.004700846876489959,1.004261223488352,0.010444191153138759,0.025396433412943842,0.2288982486402833,1.0258311542298981,0.26907954964012837,0.20141858594348247,0.0,0.5610054151536722,0.38775061638820946,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,011,623,0,0.0,1.0003136155716827,3.4486822409479976e-05,1.0041717059060316,-0.0024697565392285264,0.02505209930146019,0.22798813580706412,1.02621830655659,0.0028024989961719852,0.20126646057723502,0.0,0.563154707808065,0.3853641439636812,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,100,626,0,0.0,-1.0000339654342862,-0.0006265568535822356,-1.0033244935339916,0.0003252808581506445,0.02520603384419379,0.22957636782206733,1.0263027053107692,0.03464573073690392,0.20219190776611215,0.0,-0.56276918764136,-0.3855897463962108,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,101,622,0,0.0,-1.0000680279896892,-0.003539137840450598,-1.0051809482815732,-0.005368638038192208,0.025136907225291594,0.2273738046134359,1.0258509727043108,0.2021952278096826,0.2005775293136233,0.0,-0.5616663687148276,-0.3872426262422047,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,110,624,0,0.0,1.0003493297356336,-0.00023862510835228953,1.003998913544402,-0.0027415111063254626,0.024880920523000415,0.22842184999548698,1.0263458062858664,-0.015107834986988353,0.20164398697232905,0.0,0.563280023823765,0.3852241422373028,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только остаточная CFO +0.5 Гц,111,634,0,0.0,0.9993172683863958,-0.004973612686637713,1.0043640009783315,-0.013679792148858297,0.026371452807718986,0.2322349575295939,1.026243513361753,-0.2854746049298769,0.2047590780151857,0.0,0.564523012585757,0.38216081009474,0.5629914300776463,0.38522391595958455,0,0.9999280444571523 +только дробный тайминг +0.45 отсчёта,000,618,0,0.0,-1.0003638012416773,-3.0252294566842336e-17,-1.0005356098392824,-3.7044751764603454e-17,0.010475350747601793,3.2440759102727786e-16,1.0003638012416773,1.7359202118856331e-15,0.008457735275899755,0.0,-0.7002418470555495,-1.889007563240263e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,001,626,0,0.0,-1.0066732143452937,-4.0565671076486225e-17,-1.00691435655856,-4.4394892301683e-17,0.010452756320897299,3.4228529340362064e-16,1.0066732143452937,2.3249870198486524e-15,0.010319843387043347,0.0,-0.7046583554098391,-2.609488810820273e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,010,625,0,0.0,0.9996027050274428,-9.712055303191803e-18,1.0000230291841714,-7.168164568802029e-18,0.010349486168542758,3.2499701805702617e-16,0.9996027050274428,-5.482731339619978e-16,0.008246199029850996,0.0,0.6997090894545838,-9.082718025071803e-18,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,011,623,0,0.0,0.9933400871648654,-3.09980102937199e-17,0.9931306357271391,-3.472607047820509e-17,0.010346435368622857,3.3191797815916734e-16,0.9933400871648654,-1.792013727894548e-15,0.01019642312258512,0.0,0.695325337169614,-2.3968301829757193e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,100,626,0,0.0,-0.9934302797691696,5.710805323666649e-17,-0.9933071060195908,5.649654133962659e-17,0.010440463684732759,3.3398785380709777e-16,0.9934302797691696,-3.290863708919959e-15,0.010229384089777475,0.0,-0.6953884708373359,4.22452035604784e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,101,622,0,0.0,-0.9996160803149469,1.0068447487737392e-17,-0.999591221030594,5.095173944048246e-18,0.010303765244567465,3.1909266230762487e-16,0.9996160803149469,-5.677845403307687e-16,0.008284727942094228,0.0,-0.6997184519845104,9.332218555871944e-18,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,110,624,0,0.0,1.0068601181800312,5.1274568470286146e-17,1.0071179752064987,4.41633107541207e-17,0.010284552131954717,3.165606839390655e-16,1.0068601181800312,2.921491693734847e-15,0.010265914506814007,0.0,0.7047891857000755,3.3590551978438037e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только дробный тайминг +0.45 отсчёта,111,634,0,0.0,1.000139444652914,-1.4804421122525754e-17,1.0001928569904543,-1.551872881456763e-17,0.010528991154430876,3.3383587290276977e-16,1.000139444652914,-8.482542163393518e-16,0.008496080555841655,0.0,0.7000848003172331,-1.2648535487033274e-17,0.6999871908463614,-2.2853116588788615e-18,0,0.9999310608776122 +только gain 0.8 exp(j0.4),000,618,0,0.0,-1.0002931436799984,-3.0749782282223135e-17,-1.0001991508489865,-4.784243150873803e-17,0.01035709351948793,3.374807398016914e-16,1.0002931436799984,1.7713674781598607e-15,0.00825752103805889,0.0,-0.5601849711902923,-4.6682887991908416e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),001,626,0,0.0,-0.9999796510040615,-4.4847758642261754e-17,-0.9999608461451065,-3.823838122535474e-17,0.010322664900678938,3.452168453310068e-16,0.9999796510040615,2.587879187532213e-15,0.008200634420287619,0.0,-0.5600094087696681,-5.4568814526207665e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),010,625,0,0.0,0.999665610102756,-1.5328703374741225e-17,0.9997287758415596,-2.930001910058419e-17,0.010214140548392205,3.4254527550932965e-16,0.999665610102756,-8.776577063978714e-16,0.008056123282006349,0.0,0.5598335393314321,2.0859494169813997e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),011,623,0,0.0,1.0000428840677504,-2.7867207539056634e-17,1.0000526981049074,-4.428089958865185e-17,0.010198050251563857,3.332117265358971e-16,1.0000428840677504,-1.593680383785695e-15,0.008048443595290694,0.0,0.5600448206008745,1.384878310661252e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),100,626,0,0.0,-1.000125130099672,5.609609395039414e-17,-1.0001063003401705,5.694612309865554e-17,0.010300836630339,3.4519657615267763e-16,1.000125130099672,-3.2089743184576383e-15,0.008180893888591846,0.0,-0.5600908800898492,1.9575581223834506e-18,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),101,622,0,0.0,-0.9996849230913984,2.3329385177129972e-17,-0.9996385783577553,2.0259028300406906e-17,0.010177697810059335,3.1641855090325095e-16,0.9996849230913984,-1.3264384101346905e-15,0.008061036379932916,0.0,-0.5598443550068715,-1.6379514748914518e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),110,624,0,0.0,1.000167131713513,4.9211323157627446e-17,1.0000061919503656,7.343065997585499e-17,0.01016122023731122,3.50800124033128e-16,1.000167131713513,2.8223391809253912e-15,0.008053662299073058,0.0,0.560114401867428,5.701802344448213e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +только gain 0.8 exp(j0.4),111,634,0,0.0,1.000067444520121,-2.1521286820925234e-18,0.9999807794187555,3.710105026339808e-18,0.010336983321136441,3.349680700082323e-16,1.000067444520121,-1.2447380416433816e-16,0.008248570840346733,0.0,0.5600585749651731,2.825048565631281e-17,0.5600208046307471,2.9453735999436935e-17,0,0.9999345849172553 +SRO+CFO+тайминг+gain,000,618,0,0.0,-1.0008739380208023,0.004498948845240244,-1.005080406028307,-0.004361719690771267,0.026024678539132717,0.22814068318270816,1.0268173236880707,-0.25884438081360245,0.20143911283968863,0.0,-0.45206512025923085,-0.3063209888858998,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,001,626,0,0.0,-0.9823019021809795,0.001204055473202981,-0.9878249499315466,4.156196054697222e-05,0.027395732431980575,0.22541011703430533,1.008091068292765,0.05489278772153692,0.19789849243314606,0.0,-0.4426873177416418,-0.30208300435230034,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,010,625,0,0.0,0.999362255013195,0.004654117666767775,1.002986936682864,0.010501764441009346,0.0259608260786354,0.22881066805317504,1.0254939126135476,0.2713706497876178,0.20140909844363705,0.0,0.44856457681225054,0.30997676707616456,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,011,623,0,0.0,1.017915216676135,0.0021017602527817316,1.0217547138909593,-0.00245080190836759,0.027329356258693597,0.23199593146838193,1.0442741764209607,0.005174664040996683,0.20800775846795144,0.0,0.4577050318370308,0.31454322851352845,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,100,626,0,0.0,-1.0176467683195116,-0.0027368196688400304,-1.0220645803845851,0.00028675669554682927,0.02735493603936188,0.23366851397382177,1.0443833063543895,0.03701711192526389,0.20903063165815075,0.0,-0.4573885058595173,-0.3147464836921453,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,101,622,0,0.0,-0.9997132505633392,-0.003477528412469562,-1.0044639688035528,-0.00542350542258045,0.025798700852509238,0.22726189467977656,1.025482843217309,0.2044860738237234,0.20054343328903368,0.0,-0.44908510579133737,-0.30955509976178675,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,110,624,0,0.0,0.9827607716190412,-0.002308901450231822,0.9870492042172159,-0.0026420834401516876,0.026584820479141544,0.2244138076193021,1.008300020364011,-0.012738449607494238,0.19734362767979766,0.0,0.4432343182823996,0.30172687552483723,0.45028557339382663,0.308077559953349,0,0.9999251628507277 +SRO+CFO+тайминг+gain,111,634,0,0.0,0.9996435990296828,-0.0049247567236749635,1.0047359022406326,-0.013658464529557147,0.02684179097279431,0.2323116906510399,1.0265781789685229,-0.2830263105576786,0.2049269126760559,0.0,0.4516422982133428,0.3057502139069036,0.45028557339382663,0.308077559953349,0,0.9999251628507277 diff --git a/data/processed/lab043_diagnostics/input_manifest.json b/data/processed/lab043_diagnostics/input_manifest.json new file mode 100644 index 0000000..2b16b4c --- /dev/null +++ b/data/processed/lab043_diagnostics/input_manifest.json @@ -0,0 +1,12 @@ +[ + { + "metadata_file": "lab043_prbs11_s_20260819_134831.json", + "iq_file": "lab043_prbs11_s_20260819_134831.npy", + "verified_sha256": "d573d9bd0332a0473be1ffe4ed149dea0281c4dbcc4c4c9c199642d072441c5a" + }, + { + "metadata_file": "lab043_prbs11_s_20260819_150001.json", + "iq_file": "lab043_prbs11_s_20260819_150001.npy", + "verified_sha256": "a8216afd7dd4cab7c3ba5190b5710a7a7e5409022ff2d8e595f2de9fb66fb98e" + } +] diff --git a/data/processed/lab043_diagnostics/phase_sweep.csv b/data/processed/lab043_diagnostics/phase_sweep.csv new file mode 100644 index 0000000..08a9d66 --- /dev/null +++ b/data/processed/lab043_diagnostics/phase_sweep.csv @@ -0,0 +1,241 @@ +capture,phase,marker_start_symbol,marker_score,bit_errors,ber,errors_000,errors_001,errors_other,negative_mean_i,positive_mean_i,negative_p95_i,positive_p05_i,eye_opening_i_p05_p95,negative_std_i,positive_std_i +lab043_prbs11_s_20260819_134831,0,586,0.924068356732625,157,0.0314,68,10,79,-0.6659855894081477,1.3321491526148732,0.02197636699212176,0.5153775876666505,0.4934012206745288,0.44149976728803997,0.48611989882415996 +lab043_prbs11_s_20260819_134831,1,586,0.9262820060960705,149,0.0298,68,11,70,-0.6675787901438206,1.3305648488916857,0.013431160824582315,0.5196217955495075,0.5061906347249252,0.4357475541277015,0.48112700759873595 +lab043_prbs11_s_20260819_134831,2,586,0.9282879781464108,138,0.0276,67,11,60,-0.6690672324773898,1.3290847185615742,0.00014108579110438583,0.5207803237329575,0.5206392379418531,0.43055048660654,0.4766579101640469 +lab043_prbs11_s_20260819_134831,3,586,0.9300999930009172,131,0.0262,66,12,53,-0.6704536467219613,1.3277060465584962,-0.01106213573751081,0.5197077007403111,0.5307698364778219,0.4259001559804359,0.47269968728744854 +lab043_prbs11_s_20260819_134831,4,586,0.9317365070503667,125,0.025,65,12,48,-0.6717345798917904,1.326432266585467,-0.015315377723127056,0.5258536899544993,0.5411690676776264,0.42178857657272195,0.469238852656125 +lab043_prbs11_s_20260819_134831,5,586,0.9332057315555816,123,0.0246,66,12,45,-0.6729162505234412,1.325257194832493,-0.029152140390238588,0.5296849180093909,0.5588370583996295,0.41820532028934493,0.46626504041157174 +lab043_prbs11_s_20260819_134831,6,586,0.9345250635092867,119,0.0238,66,12,41,-0.6739969909846792,1.3241824896191439,-0.033574355942173346,0.5319286479373455,0.5655030038795189,0.4151412062825228,0.463766800199258 +lab043_prbs11_s_20260819_134831,7,586,0.9357003336616172,115,0.023,65,14,36,-0.6749776845704568,1.323207272583108,-0.037353314517459706,0.5265870431201793,0.5639403576376391,0.41258696804320005,0.46173546836226703 +lab043_prbs11_s_20260819_134831,8,586,0.9367402353717993,113,0.0226,65,14,34,-0.6758620135490878,1.3223278820191957,-0.037842149618524834,0.5291888031993269,0.5670309528178518,0.4105321757386238,0.4601594742611121 +lab043_prbs11_s_20260819_134831,9,586,0.9376537191227973,110,0.022,65,15,30,-0.6766494157506379,1.3215448769579816,-0.03743961249028052,0.5318021840719668,0.5692417965622473,0.40896472824925595,0.4590233575126271 +lab043_prbs11_s_20260819_134831,10,586,0.9384502410644282,107,0.0214,65,16,26,-0.677341811262274,1.3208563480347628,-0.037301566197318094,0.532061466340558,0.569363032537876,0.40787072253200674,0.4583189793636613 +lab043_prbs11_s_20260819_134831,11,586,0.9391426846547335,107,0.0214,64,17,26,-0.6779379018155665,1.3202635862679672,-0.0386775782025146,0.5369355209215974,0.575613099124112,0.40724688774211454,0.4580375046444359 +lab043_prbs11_s_20260819_134831,12,586,0.9397258213443209,112,0.0224,65,21,26,-0.6784389875676701,1.3197652987609885,-0.04064856701239005,0.531323197728947,0.571971764741337,0.4070801352524581,0.4581676638964428 +lab043_prbs11_s_20260819_134831,13,586,0.9402067944573552,112,0.0224,65,22,25,-0.6788469905867284,1.3193595741792135,-0.036783207169693036,0.5327464083048169,0.56952961547451,0.4073557444063936,0.45869865551571565 +lab043_prbs11_s_20260819_134831,14,586,0.9405964679420825,113,0.0226,65,23,25,-0.6791619835150654,1.3190463402859751,-0.03608819507699531,0.5277087370562249,0.5637969321332202,0.40806611659058806,0.45962004933566947 +lab043_prbs11_s_20260819_134831,15,586,0.9408956225107673,111,0.0222,63,25,23,-0.6793810337222935,1.3188285133347917,-0.03549528662576219,0.5252491807356717,0.5607444673614339,0.4092038030467577,0.46092520510912455 +lab043_prbs11_s_20260819_134831,16,586,0.9411076281687515,110,0.022,61,29,20,-0.67950746578176,1.318702787317939,-0.032252812033025005,0.5243189208677008,0.5565717329007258,0.410757389355189,0.46260119187367693 +lab043_prbs11_s_20260819_134831,17,586,0.9412320107209128,115,0.023,62,32,21,-0.6795404267468858,1.3186700104188331,-0.03157895893808972,0.5237762185105839,0.5553551774486736,0.4127140648728039,0.4646388733075375 +lab043_prbs11_s_20260819_134831,18,586,0.9412757814507751,117,0.0234,60,36,21,-0.6794757338502475,1.3187343420468016,-0.025614823610149693,0.5179247387965543,0.543539562406704,0.41506765641744686,0.4670386304128064 +lab043_prbs11_s_20260819_134831,19,586,0.9412398992116264,113,0.0226,58,36,19,-0.6793181657338452,1.3188910302455215,-0.022137833134507123,0.510578536931114,0.5327163700656211,0.4178076759422869,0.4697875706909071 +lab043_prbs11_s_20260819_134831,20,586,0.9411251741182902,118,0.0236,59,41,18,-0.6790663612863513,1.3191414285253793,-0.0216130554984655,0.5019142224580972,0.5235272779565627,0.4209281968087695,0.4728802868347639 +lab043_prbs11_s_20260819_134831,21,586,0.9409315145529543,120,0.024,60,44,16,-0.6787199031825831,1.3194859518810607,-0.015929683893213566,0.4885202605775044,0.504449944470718,0.4244173310060206,0.4763104488794568 +lab043_prbs11_s_20260819_134831,22,586,0.9406617037707291,130,0.026,62,51,17,-0.6782764561014185,1.3199269225924062,-0.016354104853254635,0.4822156559631635,0.49856976081641813,0.4282677724451274,0.4800718169861131 +lab043_prbs11_s_20260819_134831,23,586,0.9403148986756253,135,0.027,63,55,17,-0.6777378301736409,1.320462540637062,-0.01262301143745062,0.4744610404502794,0.4870840518877301,0.43247548180508505,0.48416186899767083 +lab043_prbs11_s_20260819_134831,24,586,0.9398849757013514,141,0.0282,63,58,20,-0.6771017996144919,1.3210950193701922,-0.005833199267161328,0.46803567614730995,0.47386887541447126,0.4370356927198379,0.4885712909655701 +lab043_prbs11_s_20260819_134831,25,586,0.9393778126621425,146,0.0292,63,62,21,-0.6763648500132214,1.3218278535768004,0.003456825197673221,0.4644765739899177,0.4610197487922445,0.44194060503515714,0.4933005680073378 +lab043_prbs11_s_20260819_134831,26,586,0.9387908148161194,151,0.0302,62,66,23,-0.675530514772735,1.3226575295857885,0.008019679423990251,0.45657705807380683,0.4485573786498166,0.4471869580679684,0.4983497256037323 +lab043_prbs11_s_20260819_134831,27,586,0.9381215403366853,157,0.0314,62,72,23,-0.6745939465758469,1.3235888676451593,0.013340574037275916,0.4514373853652713,0.4380968113279954,0.45277225148973954,0.5037160135219635 +lab043_prbs11_s_20260819_134831,28,586,0.9373635252911054,165,0.033,61,81,23,-0.6735543304567546,1.3246226781696488,0.02257720722841847,0.44645592409363916,0.42387871686522066,0.45869685707129143,0.5094001354811456 +lab043_prbs11_s_20260819_134831,29,586,0.936524190160605,174,0.0348,61,88,25,-0.6724100408531668,1.3257605776438193,0.03268969195234952,0.447688578175528,0.4149988862231785,0.4649548708482804,0.5154003071853183 +lab043_prbs11_s_20260819_134831,30,586,0.9355918903763845,181,0.0362,61,94,26,-0.6711605214115208,1.327003119314351,0.039616621174715094,0.442086418975391,0.40246979780067593,0.47154403847070653,0.5217154669786309 +lab043_prbs11_s_20260819_134831,31,586,0.9345650504372153,194,0.0388,60,106,28,-0.6698043859971023,1.3283516815752792,0.04873288080784271,0.43784633870190903,0.3891134578940663,0.4784711527799905,0.5283499711359179 +lab043_prbs11_s_20260819_134831,32,586,0.9334372177221079,213,0.0426,62,122,29,-0.66833631864754,1.329811550702705,0.05800173748918579,0.4308454800266391,0.3728437425374533,0.48573526525380833,0.5353073144721195 +lab043_prbs11_s_20260819_134831,33,586,0.9322069563437837,224,0.0448,62,132,30,-0.6667558227123775,1.3313832205736105,0.07183704582068064,0.4200540116296282,0.34821696580894757,0.4933370047666099,0.5425895417234553 +lab043_prbs11_s_20260819_134831,34,586,0.9308676778745424,229,0.0458,61,137,31,-0.6650624901094689,1.3330670969912617,0.08375347838224566,0.40546075477407545,0.3217072763918298,0.5012826302551984,0.5501992829879024 +lab043_prbs11_s_20260819_134831,35,586,0.9294147941803909,244,0.0488,62,152,30,-0.6632520775741212,1.3348673995244176,0.10190967877819591,0.39073059605428945,0.28882091727609355,0.5095711832383397,0.5581422912475964 +lab043_prbs11_s_20260819_134831,36,586,0.9278396478821677,257,0.0514,62,165,30,-0.6613194777589604,1.3367892069991676,0.11985635205964691,0.3788278732975236,0.2589715212378767,0.5182126298932007,0.5664278333246304 +lab043_prbs11_s_20260819_134831,37,586,0.9261356027963862,273,0.0546,64,178,31,-0.6592656494139274,1.3388315660195764,0.1374325118235685,0.36301249548787495,0.22557998366430645,0.5272083294393253,0.5750614108765331 +lab043_prbs11_s_20260819_134831,38,586,0.9242920971965133,297,0.0594,65,197,35,-0.6570843562930794,1.3410006780061239,0.14880389142562775,0.3480637258157819,0.19925983439015413,0.5365693950594468,0.5840485619048263 +lab043_prbs11_s_20260819_134831,39,586,0.9223018373171441,322,0.0644,64,222,36,-0.6547732839066877,1.3432988445235858,0.16606967115149351,0.33663340020839533,0.1705637290569018,0.5463009156883587,0.5933985806688477 +lab043_prbs11_s_20260819_134831,40,586,0.9201532944540215,339,0.0678,65,236,38,-0.6523314659509368,1.3457270264795822,0.18650168471123202,0.3266940848866488,0.1401924001754168,0.5564127909044201,0.6031221883620318 +lab043_prbs11_s_20260819_134831,41,586,0.9178308054391655,369,0.0738,65,266,38,-0.6497500988219137,1.3482939783155041,0.2035465696098755,0.3160256768986581,0.11247910728878263,0.5669153566572448,0.6132288077392158 +lab043_prbs11_s_20260819_134831,42,586,0.9153295666582568,396,0.0792,65,290,41,-0.647025535097874,1.3510033270845638,0.223987231110259,0.2999646655674145,0.07597743445715552,0.577819036655808,0.6237312564604816 +lab043_prbs11_s_20260819_134831,43,586,0.9126318234289537,420,0.084,66,311,43,-0.644159537189783,1.3538533202177383,0.24104645218505208,0.2899788847326014,0.04893243254754934,0.5891328825106152,0.6346412087590747 +lab043_prbs11_s_20260819_134831,44,586,0.9097222194150218,441,0.0882,66,331,44,-0.6411415234166019,1.3568544803041127,0.26513564174587706,0.27492581777459335,0.009790176028716291,0.6008753124576476,0.6459713688956571 +lab043_prbs11_s_20260819_134831,45,586,0.9065806004094584,473,0.0946,68,357,48,-0.6379653373922171,1.3600129293502998,0.29121035979075666,0.26386841374609254,-0.02734194604466411,0.6130581569182658,0.6577368846012867 +lab043_prbs11_s_20260819_134831,46,586,0.9031911415569012,495,0.099,66,377,52,-0.634630384904031,1.363329258250599,0.31668311605544147,0.24731231338610657,-0.0693708026693349,0.625696243445256,0.6699530002247139 +lab043_prbs11_s_20260819_134831,47,586,0.8995374131645946,527,0.1054,66,400,61,-0.6311278268066707,1.3668122567893775,0.3428397833370038,0.23154986948692444,-0.11128991385007939,0.6388051313657893,0.6826366817310998 +lab043_prbs11_s_20260819_134831,48,586,0.8955902981434892,552,0.1104,66,419,67,-0.6274474698300331,1.3704720613138126,0.3697232783241035,0.21511410024322755,-0.15460917808087596,0.6524035826895436,0.6958085568088207 +lab043_prbs11_s_20260819_134831,49,586,0.891322087704631,579,0.1158,67,439,73,-0.6235924249179406,1.3743055782527218,0.400749845376256,0.19920841583837662,-0.2015414295378794,0.6665100890035865,0.7094851844524234 +lab043_prbs11_s_20260819_134831,50,586,0.8867076363376143,600,0.12,66,456,78,-0.6195497505552633,1.378325676851108,0.42732126936756015,0.1819789447439618,-0.24534232462359834,0.6811452040759839,0.7236856925878135 +lab043_prbs11_s_20260819_134831,51,586,0.881719142769753,625,0.125,65,468,92,-0.6153097718596897,1.3825419779632206,0.45794752658444177,0.1669315922139847,-0.29101593437045703,0.696330768686415,0.7384354853038256 +lab043_prbs11_s_20260819_134831,52,586,0.8763223652482677,642,0.1284,64,482,96,-0.6108685371982946,1.3869584111546276,0.4906368049548605,0.1461684186673358,-0.34446838628752474,0.7120904154018045,0.7537595380275287 +lab043_prbs11_s_20260819_134831,53,586,0.8704746972554525,670,0.134,66,500,104,-0.6062169024152849,1.3915840695168298,0.525744095863514,0.12892186411425946,-0.3968222317492546,0.7284475325311637,0.769671733449158 +lab043_prbs11_s_20260819_134831,54,586,0.8641427345485114,686,0.1372,67,510,109,-0.6013434696876506,1.3964302872232497,0.562700896601655,0.11374915370102695,-0.44895174290062806,0.7454271004301627,0.7862103546525474 +lab043_prbs11_s_20260819_134831,55,586,0.8572765618668114,706,0.1412,66,520,120,-0.5962397244734674,1.40150553126751,0.6068766875647564,0.08437798678887234,-0.5224987007758841,0.7630579668838491,0.8033987049483196 +lab043_prbs11_s_20260819_134831,56,586,0.8498313583912235,732,0.1464,66,536,130,-0.5908925982655493,1.4068227971774974,0.6409859493646387,0.05890271495647785,-0.5820832344081609,0.7813744741639989,0.8212701356474056 +lab043_prbs11_s_20260819_134831,57,586,0.8417564404146284,754,0.1508,66,552,136,-0.5852960729058512,1.4123880695036752,0.6809752105860043,0.03476761371093425,-0.64620759687507,0.8004025111153523,0.8398494115453475 +lab043_prbs11_s_20260819_134831,58,586,0.8329858684074203,775,0.155,67,557,151,-0.5794334810494629,1.4182179225144356,0.7136668163278557,0.008780353876684476,-0.7048864624511711,0.8201776614132081,0.8591791205615152 +lab043_prbs11_s_20260819_134831,59,586,0.8234585776646915,792,0.1584,68,561,163,-0.5732934134312082,1.4243237017614665,0.7548409680838847,-0.021568938316315397,-0.7764099064002,0.8407347762247983,0.8792864440997307 +lab043_prbs11_s_20260819_134831,60,586,0.8131039255833545,811,0.1622,68,567,176,-0.5668633014985085,1.4307179056099797,0.7967125260128085,-0.051172663610445236,-0.8478851896232538,0.8621172682286867,0.9002172960821052 +lab043_prbs11_s_20260819_134831,61,586,0.8018483842993291,827,0.1654,67,570,190,-0.5601248196928548,1.437418757281896,0.8395386361391474,-0.07797079235106036,-0.9175094284902077,0.8843633963438916,0.9220060340503344 +lab043_prbs11_s_20260819_134831,62,586,0.7896092902158751,850,0.17,66,576,208,-0.553066734719211,1.4444374273414464,0.8872602283459761,-0.1133329387461816,-1.0005931670921577,0.9075132978603166,0.9446982087798524 +lab043_prbs11_s_20260819_134831,63,586,0.7763032590187886,865,0.173,66,581,218,-0.5456677269396993,1.451795116369896,0.9350567347422475,-0.14902898947593413,-1.0840857242181816,0.9316171298625062,0.968350711856813 +lab043_prbs11_s_20260819_134831,64,586,0.7618314567782936,895,0.179,66,586,243,-0.5379110294744874,1.4595084976147192,0.9890940632783062,-0.18543382871990735,-1.1745278919982136,0.9567265963199282,0.9930063980804443 +lab043_prbs11_s_20260819_134831,65,586,0.7460976021827928,926,0.1852,65,593,268,-0.5297751578077421,1.4675989356143988,1.0459640950498237,-0.22967158639287796,-1.2756356814427017,0.9828921088718267,1.0187127660833912 +lab043_prbs11_s_20260819_134831,66,586,0.7290153781645049,950,0.19,66,597,287,-0.5212406198426084,1.4760858136148287,1.1029437579297552,-0.266133120395618,-1.369076878325373,1.0101678921179758,1.0455296835842123 +lab043_prbs11_s_20260819_134831,67,586,0.710461323169548,984,0.1968,65,599,320,-0.5122838088189564,1.484992606547404,1.1630707747921019,-0.3038538060469409,-1.4669245808390428,1.038619120840168,1.0735199645458262 +lab043_prbs11_s_20260819_134831,68,586,0.6903395923304086,1008,0.2016,64,600,344,-0.5028781825932538,1.4943457083346703,1.2251148829423737,-0.33704909723940946,-1.5621639801817833,1.068311857276025,1.102753959176679 +lab043_prbs11_s_20260819_134831,69,586,0.6685455540236415,1038,0.2076,63,600,375,-0.49300138999685317,1.5041673453282185,1.2848137905322061,-0.37650198068558777,-1.661315771217794,1.0993096803844238,1.1332953756268866 +lab043_prbs11_s_20260819_134831,70,586,0.6449873260393597,1070,0.214,65,601,404,-0.482620678572517,1.5144900870836508,1.3545372228715682,-0.4213864371305915,-1.7759236600021597,1.1316951524537058,1.1652176787424533 +lab043_prbs11_s_20260819_134831,71,586,0.61955659042444,1103,0.2206,65,604,434,-0.47170675180452304,1.5253430665142893,1.4224890529940577,-0.46611556974109564,-1.8886046227351534,1.1655454175360629,1.198607012301622 +lab043_prbs11_s_20260819_134831,72,587,0.5954608661168048,1366,0.2732,222,1,1143,-1.4882440898321838,0.5144824427795643,1.115475074876603,-2.4578561143309803,-3.5733311892075834,1.652595224158021,1.6394984667937402 +lab043_prbs11_s_20260819_134831,73,587,0.5709718112566252,1370,0.274,222,1,1147,-1.4886710879758849,0.5140578291488311,1.1240524274258572,-2.4600623807981563,-3.5841148082240135,1.653657328172379,1.6403870173298185 +lab043_prbs11_s_20260819_134831,74,587,0.5446275541078681,1374,0.2748,219,1,1154,-1.4891849142983795,0.5135468722194412,1.1310218677319783,-2.462103167092029,-3.5931250348240074,1.6556985703278773,1.6422474956269457 +lab043_prbs11_s_20260819_134831,75,587,0.5164085734545684,1374,0.2748,219,1,1154,-1.4897889472280719,0.5129462124293643,1.1374784850017052,-2.4772739774713615,-3.6147524624730667,1.65873752264181,1.6451054533683516 +lab043_prbs11_s_20260819_134831,76,587,0.48630627290417194,1381,0.2762,219,1,1161,-1.4904810449524872,0.5122579796304141,1.1495834346413443,-2.4938258320080964,-3.6434092666494404,1.6627920051533485,1.6489743203022946 +lab043_prbs11_s_20260819_134831,77,587,0.4543565461515319,1374,0.2748,217,1,1156,-1.4912557456510007,0.5114876051424236,1.161575521004454,-2.5107959163324423,-3.6723714373368965,1.6678553779471745,1.6538504617277194 +lab043_prbs11_s_20260819_134831,78,587,0.42067304571618463,1384,0.2768,216,2,1166,-1.4921109152545389,0.5106372111170456,1.1648387165353824,-2.5226230513328187,-3.687461767868201,1.6739331412142666,1.659749228129709 +lab043_prbs11_s_20260819_134831,79,587,0.3853436264563874,1402,0.2804,215,2,1185,-1.4930483743722471,0.5097049871120807,1.1745621306734675,-2.5302126735933013,-3.7047748042667688,1.6810366333122717,1.6666760725905139 +lab043_prbs11_s_20260819_134831,80,587,0.3485344051319883,1406,0.2812,215,3,1188,-1.4940553113657125,0.5087036732210931,1.181238696032629,-2.5397457650077806,-3.72098446104041,1.6891650062395938,1.6746211214953113 +lab043_prbs11_s_20260819_134831,81,587,0.31045914415176334,1422,0.2844,214,3,1205,-1.495137210113542,0.5076278161894447,1.1891782501805872,-2.5449063798040976,-3.734084629984685,1.6983345677190063,1.6836036376672632 +lab043_prbs11_s_20260819_134831,82,587,0.27136197406778606,1438,0.2876,214,5,1219,-1.496291483709909,0.5064799884767431,1.2009326483837413,-2.55998523912394,-3.760917887507681,1.7085401075305717,1.6936295291022385 +lab043_prbs11_s_20260819_134831,83,585,0.2527395916761685,1121,0.2242,70,7,1044,-0.49832793585463625,1.4988705448402035,1.3736296634717124,-0.3334044605643227,-1.7070341240360352,1.1498568488757268,1.1630400481057463 +lab043_prbs11_s_20260819_134831,84,585,0.2985711939100713,1094,0.2188,70,7,1017,-0.5071736704878383,1.4900742080071077,1.306950074506932,-0.2821894755818229,-1.5891395500887548,1.1133218183388403,1.1271796155776306 +lab043_prbs11_s_20260819_134831,85,585,0.3434385869149458,1060,0.212,70,7,983,-0.5156412864220489,1.481653878320635,1.238309500008264,-0.23431956040204197,-1.472629060410306,1.0781963694593713,1.0927430430681224 +lab043_prbs11_s_20260819_134831,86,585,0.38698516659094745,1018,0.2036,71,7,940,-0.5237566007346964,1.4735838828753094,1.1770014757503542,-0.1940133996180635,-1.3710148753684177,1.0444097228616873,1.0596584402993738 +lab043_prbs11_s_20260819_134831,87,585,0.42891254022572756,976,0.1952,72,7,897,-0.5315316853779258,1.4658522171331596,1.1158536131187755,-0.15382036403424967,-1.2696739771530252,1.0119128881113073,1.0278751358533167 +lab043_prbs11_s_20260819_134831,88,585,0.4689855704108766,932,0.1864,72,6,854,-0.5389839331261742,1.4584415854473267,1.0576565070622677,-0.1095902554305312,-1.167246762492799,0.9806503187127675,0.9973358211599097 +lab043_prbs11_s_20260819_134831,89,585,0.5070300136098886,899,0.1798,72,5,822,-0.5461271185540193,1.4513382901654697,1.0045603484971137,-0.06702916421708442,-1.0715895127141981,0.9505667174999127,0.9679876281126482 +lab043_prbs11_s_20260819_134831,90,585,0.5429270343462076,865,0.173,71,5,789,-0.5529764715608292,1.4445271864375158,0.952859630023093,-0.03363830647057635,-0.9864979364936693,0.9216212008679424,0.939792193489485 +lab043_prbs11_s_20260819_134831,91,585,0.5765869412729584,827,0.1654,71,5,751,-0.5595471664191328,1.4379931847295981,0.9034910289027511,-0.0051241857010318895,-0.908615214603783,0.8937655945449989,0.9127013034132604 +lab043_prbs11_s_20260819_134831,92,585,0.6080018658836429,802,0.1604,71,5,726,-0.565847950876964,1.4317275861442875,0.8527865839941436,0.02742614804610433,-0.8253604359480393,0.8669638401674792,0.8866743288468447 +lab043_prbs11_s_20260819_134831,93,585,0.6371972137239146,771,0.1542,72,5,694,-0.5718913239957532,1.4257179614194595,0.8055769557401598,0.059359446792131246,-0.7462175089480285,0.841175071703383,0.8616710556103849 +lab043_prbs11_s_20260819_134831,94,585,0.6642050846943454,720,0.144,72,5,643,-0.577690492707118,1.4199511773758093,0.754519442293612,0.09148898351324294,-0.663030458780369,0.8163644044322313,0.8376635450795781 +lab043_prbs11_s_20260819_134831,95,585,0.6891208826216834,688,0.1376,72,5,611,-0.5832531920360579,1.4144195421835288,0.7053548620828973,0.12306607813185709,-0.5822887839510402,0.7924936831680537,0.8146100496025348 +lab043_prbs11_s_20260819_134831,96,585,0.7120259259176022,653,0.1306,72,5,576,-0.5885887840703432,1.409113746036152,0.6575101744869604,0.15823460360088815,-0.4992755708860722,0.7695354321724214,0.792484855883515 +lab043_prbs11_s_20260819_134831,97,585,0.7330186397789855,611,0.1222,72,5,534,-0.5937108647462519,1.4040202689220558,0.613996298013993,0.19135171080536673,-0.4226445872086263,0.7474669348659341,0.7712588089510071 +lab043_prbs11_s_20260819_134831,98,585,0.7522476655823649,589,0.1178,71,5,513,-0.5986212834466316,1.3991372717860189,0.5830232151853914,0.21322800623133015,-0.3697952089540612,0.7262562643096853,0.7509101876935861 +lab043_prbs11_s_20260819_134831,99,585,0.7698198513523438,560,0.112,71,5,484,-0.6033326097469355,1.3944522552456682,0.5431868983285664,0.24208954971997185,-0.3010973486085945,0.7058744883690341,0.73140495570644 +lab043_prbs11_s_20260819_134831,100,585,0.7858570785133816,536,0.1072,71,5,460,-0.6078495832608549,1.3899605061550409,0.507236529655775,0.27000713147359984,-0.2372293981821752,0.6863067964254232,0.712724678773947 +lab043_prbs11_s_20260819_134831,101,585,0.8004778294427455,508,0.1016,70,5,433,-0.6121795102409284,1.3856547590623716,0.4666818227893659,0.2887793655390671,-0.1779024572502988,0.6675316057633957,0.6948456670415167 +lab043_prbs11_s_20260819_134831,102,585,0.8138072478497561,480,0.096,70,5,405,-0.6163303747520613,1.3815270744886756,0.43434103163991616,0.3119829860037918,-0.12235804563612435,0.649527938907296,0.6777514675882365 +lab043_prbs11_s_20260819_134831,103,585,0.825936438868285,459,0.0918,70,5,384,-0.6203088050360722,1.37757086120665,0.3927214845425293,0.3336555778701171,-0.05906590667241218,0.6322742335503748,0.6614207630231035 +lab043_prbs11_s_20260819_134831,104,585,0.8369754762106737,432,0.0864,70,5,357,-0.6241199472811542,1.3737810017662875,0.36629452089459297,0.3448151414077251,-0.021479379486867844,0.615756453350285,0.6458306532270578 +lab043_prbs11_s_20260819_134831,105,585,0.8470272757368669,402,0.0804,70,6,326,-0.6277684995729623,1.3701528243177519,0.333442582025133,0.3542206598841969,0.02077807785906388,0.5999585132136214,0.630969419513661 +lab043_prbs11_s_20260819_134831,106,585,0.8561746296047691,384,0.0768,69,6,309,-0.6312611193271471,1.3666797086228246,0.2990540942333866,0.3724256144556353,0.07337152022224874,0.5848679922723099,0.6168204896807794 +lab043_prbs11_s_20260819_134831,107,585,0.8645047630290654,349,0.0698,69,7,273,-0.6346008355336884,1.3633586426065076,0.26837572756922323,0.3910283744580488,0.12265264688882555,0.570468199391636,0.6033714744734193 +lab043_prbs11_s_20260819_134831,108,585,0.8720816730958266,325,0.065,71,7,247,-0.6377937159608613,1.3601835923851502,0.2418574807638858,0.4035878472160463,0.1617303664521605,0.5567488890194826,0.5906071862392183 +lab043_prbs11_s_20260819_134831,109,585,0.878975348097475,315,0.063,71,8,236,-0.640844731075646,1.357149615248669,0.21130031135525065,0.4272201999021476,0.21591988854689695,0.5436956243172907,0.5785075076913357 +lab043_prbs11_s_20260819_134831,110,585,0.8852517061033369,297,0.0594,70,8,219,-0.6437568729782485,1.3542537358058342,0.18826605011066166,0.4407779277829532,0.25251187767229155,0.5312991939778278,0.5670615036523904 +lab043_prbs11_s_20260819_134831,111,585,0.8909649854387707,275,0.055,69,8,198,-0.6465348997466879,1.3514912225494646,0.16706089578027344,0.44795246091882135,0.2808915651385479,0.519551686134255,0.5562594340369276 +lab043_prbs11_s_20260819_134831,112,585,0.8961705981865468,265,0.053,70,8,187,-0.6491795112363157,1.3488613795324556,0.1511313549460599,0.4571982616136997,0.3060669066676398,0.5084438790546874,0.5460894042083458 +lab043_prbs11_s_20260819_134831,113,585,0.9009122334341652,243,0.0486,70,8,165,-0.6516954005035124,1.3463595399061596,0.1357231250740756,0.463881016601406,0.32815789152733044,0.4979671411262529,0.5365411889740964 +lab043_prbs11_s_20260819_134831,114,585,0.9052272030396968,230,0.046,70,8,152,-0.6540887508206481,1.3439795549278515,0.1156627375480785,0.4763779659763822,0.3607152284283037,0.48810532519443006,0.5276012041588062 +lab043_prbs11_s_20260819_134831,115,585,0.9091507077424639,214,0.0428,70,8,136,-0.6563598119306858,1.3417211762492216,0.09530136969884168,0.4806262430272686,0.3853248733284269,0.47885623355066814,0.5192567324340971 +lab043_prbs11_s_20260819_134831,116,585,0.9127254509326868,203,0.0406,70,8,125,-0.6585137572238061,1.339579259370184,0.07858830173998757,0.49406019172842885,0.4154718899884413,0.4702090731153832,0.5115020620239884 +lab043_prbs11_s_20260819_134831,117,585,0.9159789295941559,190,0.038,71,8,111,-0.6605507260013056,1.3375536657673492,0.0655630904105539,0.5023098290012687,0.43674673859071483,0.46215733508278645,0.504320616517932 +lab043_prbs11_s_20260819_134831,118,585,0.9189359906731527,173,0.0346,70,9,94,-0.6624724205189682,1.3356427026909496,0.047532757396522074,0.506334638995639,0.45880188159911695,0.45469447722708994,0.4977043770358277 +lab043_prbs11_s_20260819_134831,119,585,0.9216289413767563,167,0.0334,69,9,89,-0.6642845875772625,1.3338406554327418,0.03405947465219247,0.5123100715774552,0.4782505969252627,0.44781136018939566,0.4916410975859823 +lab043_prbs11_s_20260819_150001,0,566,0.8417098657962399,349,0.0698,35,0,314,-0.7439153794882191,1.2546545508320184,0.25431658178195643,0.27049012910009473,0.016173547318138304,0.6254484632423968,0.6234819346312261 +lab043_prbs11_s_20260819_150001,1,566,0.8535170004870393,316,0.0632,34,0,282,-0.7465341473446708,1.252050407127936,0.2205672564249182,0.29895849701632554,0.07839124059140734,0.6079750482923494,0.6065043063107486 +lab043_prbs11_s_20260819_150001,2,566,0.8642035402208976,285,0.057,35,0,250,-0.7490403700583727,1.2495581800735835,0.18941463870948091,0.3237550362285323,0.13434039751905139,0.5912431647847993,0.5902617438476941 +lab043_prbs11_s_20260819_150001,3,566,0.8738675296079155,254,0.0508,35,0,219,-0.751439403025019,1.2471725441797479,0.15384049086087628,0.3457923915906471,0.19195190072977084,0.5752296767714061,0.5747334451697673 +lab043_prbs11_s_20260819_150001,4,566,0.8826120011515349,233,0.0466,36,0,197,-0.7537365076353416,1.2448882674372117,0.12557351999688407,0.36993302493861413,0.24435950494173006,0.5599284069843365,0.5599088046200422 +lab043_prbs11_s_20260819_150001,5,566,0.8905311363245768,207,0.0414,35,0,172,-0.7559308233336942,1.242706205595971,0.10004855252678625,0.389722156761372,0.28967360423458577,0.545321802573142,0.5457741379730258 +lab043_prbs11_s_20260819_150001,6,566,0.8976849469112597,190,0.038,35,0,155,-0.7580274671808072,1.2406212701708208,0.07784377677452371,0.4091406079381435,0.3312968311636198,0.5314036676272855,0.5323203579380574 +lab043_prbs11_s_20260819_150001,7,566,0.9041477012162931,166,0.0332,35,0,131,-0.7600279784130624,1.2386319305210347,0.04712413013313975,0.43151542165151313,0.38439129151837337,0.5181628218191525,0.5195355944094538 +lab043_prbs11_s_20260819_150001,8,566,0.909997930914499,146,0.0292,35,0,111,-0.761935738366064,1.2367348241936194,0.023351916480462403,0.4486510510573269,0.42529913457686447,0.5055870303121734,0.5074113387877242 +lab043_prbs11_s_20260819_150001,9,566,0.9152881378652608,126,0.0252,34,0,92,-0.763758177913763,1.234922561811324,0.0016712655791742779,0.4631321305082548,0.4614608649290805,0.49366953466042657,0.49593484369741736 +lab043_prbs11_s_20260819_150001,10,566,0.9200727433534758,115,0.023,34,0,81,-0.7654893428543181,1.2332010643255618,-0.01451699159171784,0.4792643446056017,0.4937813361973195,0.48239876905779105,0.48509885409971004 +lab043_prbs11_s_20260819_150001,11,566,0.9243996298480085,97,0.0194,34,0,63,-0.7671359826435864,1.2315636199718942,-0.025025537965609947,0.48990599920324307,0.514931537168853,0.4717716215992969,0.4748970960843038 +lab043_prbs11_s_20260819_150001,12,566,0.9283108406024309,85,0.017,34,0,51,-0.7686992989460641,1.230009033796354,-0.045079690809859005,0.5002514395863755,0.5453311303962345,0.4617790488669972,0.46531949657426935 +lab043_prbs11_s_20260819_150001,13,566,0.9318464547890782,78,0.0156,34,0,44,-0.7701827028970867,1.2285339137126308,-0.05980730327558621,0.5121656105010041,0.5719729137765903,0.4524141904187549,0.4563582523644184 +lab043_prbs11_s_20260819_150001,14,566,0.9350372044926236,73,0.0146,34,0,39,-0.771585623914577,1.2271388271164583,-0.07101072758506645,0.5204197246686815,0.5914304522537479,0.4436733625330999,0.44800663970660404 +lab043_prbs11_s_20260819_150001,15,566,0.9379180038148098,66,0.0132,34,0,32,-0.7729087099113112,1.2258231297132431,-0.09012031794945441,0.5312915969196479,0.6214119148691023,0.43555013679123905,0.44026004352497944 +lab043_prbs11_s_20260819_150001,16,566,0.9405126829583762,52,0.0104,33,0,19,-0.7741587638208428,1.2245800565594886,-0.09309025247189073,0.5442500621483274,0.6373403146202181,0.42803842563689565,0.4331083381464462 +lab043_prbs11_s_20260819_150001,17,566,0.942851707959703,48,0.0096,32,1,15,-0.7753312267978071,1.2234141410423087,-0.10212819033122356,0.5465290839255482,0.6486572742567718,0.42112431648107823,0.4265400063554966 +lab043_prbs11_s_20260819_150001,18,566,0.9449571235041081,43,0.0086,32,1,10,-0.7764327432782724,1.2223187758305811,-0.1111497323314854,0.5541036182593458,0.6652533505908312,0.41480692303529265,0.4205525985222578 +lab043_prbs11_s_20260819_150001,19,566,0.9468509232912231,41,0.0082,32,1,8,-0.7774615431907207,1.2212957211111013,-0.11702021334973679,0.5569028797811826,0.6739230931309194,0.40908000089051655,0.41513679682071275 +lab043_prbs11_s_20260819_150001,20,566,0.948546535094071,38,0.0076,32,1,5,-0.7784194394063935,1.2203431741363622,-0.12929890338526115,0.5615605285849865,0.6908594319702477,0.4039316263972235,0.41028104714989594 +lab043_prbs11_s_20260819_150001,21,566,0.9500618734017223,34,0.0068,32,1,1,-0.7793036461044882,1.219463905170127,-0.13779167904761155,0.5740715701339134,0.711863249181525,0.3993585179878287,0.4059813992131384 +lab043_prbs11_s_20260819_150001,22,566,0.9514106916743266,32,0.0064,31,1,0,-0.7801262660430124,1.2186458790405945,-0.13861152238483265,0.5776106644492217,0.7162221868340544,0.3953518237493205,0.4022277574257924 +lab043_prbs11_s_20260819_150001,23,566,0.9526081281105556,31,0.0062,30,1,0,-0.7808778682099851,1.2178984740935415,-0.1440613880254913,0.5808559029715669,0.7249172909970582,0.39189939115119543,0.39901080816809203 +lab043_prbs11_s_20260819_150001,24,566,0.9536646985278557,31,0.0062,30,1,0,-0.7815593307696798,1.2172208170686831,-0.14861733700853194,0.5812320677490579,0.7298494047575899,0.3889959254410948,0.3963206692536148 +lab043_prbs11_s_20260819_150001,25,566,0.95458920148356,29,0.0058,28,1,0,-0.7821773161236624,1.2166062827697277,-0.14975389028746344,0.5794252854037184,0.7291791756911818,0.386626213482415,0.39414442913630715 +lab043_prbs11_s_20260819_150001,26,566,0.9553923010079891,29,0.0058,28,1,0,-0.7827286057937527,1.2160580717017058,-0.1542039363354876,0.5768920820989916,0.7310960184344791,0.3847837035256762,0.3924725051528404 +lab043_prbs11_s_20260819_150001,27,566,0.9560854653083438,28,0.0056,27,1,0,-0.7832127802497072,1.2155766010520472,-0.1549672160226195,0.5739162841832407,0.7288835002058602,0.38345563055182,0.3912942796375709 +lab043_prbs11_s_20260819_150001,28,566,0.9566721990787159,28,0.0056,27,1,0,-0.7836324102077048,1.2151593144603867,-0.15495033829807434,0.5683276072259524,0.7232779455240268,0.3826308221086911,0.390598507110402 +lab043_prbs11_s_20260819_150001,29,566,0.9571583729318379,25,0.005,24,1,0,-0.78398907502942,1.2148046413847864,-0.15273054345508078,0.5679351867868732,0.7206657302419539,0.38229914812809707,0.39037099808567155 +lab043_prbs11_s_20260819_150001,30,566,0.9575511614557676,26,0.0052,24,2,0,-0.7842803897592053,1.2145149534624253,-0.15347663136120857,0.5640667319159941,0.7175433632772027,0.38244617317358054,0.390603672111513 +lab043_prbs11_s_20260819_150001,31,566,0.9578576886172656,26,0.0052,23,3,0,-0.784507657929072,1.2142889544406954,-0.15029576184784055,0.5617746976095612,0.7120704594574018,0.38306276919942556,0.39128392146984536 +lab043_prbs11_s_20260819_150001,32,566,0.9580792311261221,25,0.005,22,3,0,-0.7846697079506403,1.214127809365398,-0.1480383685223169,0.5590103865816685,0.7070487551039853,0.38413417309950204,0.39239698380054905 +lab043_prbs11_s_20260819_150001,33,566,0.958223344127079,25,0.005,22,3,0,-0.7847683796286135,1.2140296887059694,-0.1423187476789247,0.5553674840126356,0.6976862316915603,0.38564961580266244,0.39393595116095764 +lab043_prbs11_s_20260819_150001,34,566,0.958287736928995,27,0.0054,23,4,0,-0.7848045304825814,1.2139937397315215,-0.1404910785978791,0.5518194085296737,0.6923104871275528,0.387595497176645,0.3958878386811557 +lab043_prbs11_s_20260819_150001,35,566,0.9582806868206216,27,0.0054,22,5,0,-0.7847736725476696,1.2140244253445,-0.13822038981787882,0.5486491289249864,0.6868695187428653,0.3899598529677354,0.39824173759932097 +lab043_prbs11_s_20260819_150001,36,566,0.9582012831543106,25,0.005,20,5,0,-0.7846797993516798,1.2141177743184135,-0.13154797891600561,0.5463319540670548,0.6778799329830605,0.39273443782438466,0.4009874774157728 +lab043_prbs11_s_20260819_150001,37,566,0.9580492238140073,27,0.0054,20,7,0,-0.7845231195774928,1.2142735791357435,-0.12510151328191355,0.5447493248674221,0.6698508381493357,0.39590514793431686,0.4041123415338196 +lab043_prbs11_s_20260819_150001,38,566,0.9578324874148139,27,0.0054,19,8,0,-0.7842965322928345,1.214498901074577,-0.12125803429269681,0.5403986788927823,0.661656713185479,0.39946484028741336,0.4076106171710976 +lab043_prbs11_s_20260819_150001,39,566,0.9575469522030455,27,0.0054,19,8,0,-0.7840074180766032,1.2147864007718503,-0.11783094449829692,0.5387342618077324,0.6565652063060293,0.4034027941377667,0.4114746178460792 +lab043_prbs11_s_20260819_150001,40,566,0.9571908913895233,29,0.0058,18,11,0,-0.7836530694346844,1.2151387706020473,-0.1077898149007571,0.5331912831795246,0.6409810980802817,0.4077083548555245,0.41569638952718135 +lab043_prbs11_s_20260819_150001,41,566,0.9567753093131954,32,0.0064,19,13,0,-0.7832308856796534,1.2155585967294074,-0.10392345570960683,0.5317296681245018,0.6356531238341087,0.41237519335539286,0.4202656625389943 +lab043_prbs11_s_20260819_150001,42,566,0.9562868164705285,34,0.0068,18,16,0,-0.7827422858508964,1.216044468038976,-0.10309576807434677,0.5314307063775014,0.6345264744518482,0.41739549305055945,0.4251789504612106 +lab043_prbs11_s_20260819_150001,43,566,0.9557285535551558,34,0.0068,16,18,0,-0.7821843394239875,1.2165992986900673,-0.09940056303014612,0.5311655248378344,0.6305660878679805,0.4227649444402489,0.430431500098699 +lab043_prbs11_s_20260819_150001,44,566,0.9551024894443848,39,0.0078,15,24,0,-0.7815594441487375,1.2172207043227743,-0.09410315492442063,0.5267703008939283,0.620873455818349,0.42847825734748884,0.4360202952182474 +lab043_prbs11_s_20260819_150001,45,566,0.9544050863778095,44,0.0088,15,29,0,-0.7808638273056479,1.2179124365883607,-0.08729533702611139,0.5206745033526428,0.6079698403787541,0.4345323063449011,0.4419368162154012 +lab043_prbs11_s_20260819_150001,46,566,0.9536340747856367,49,0.0098,15,34,0,-0.780096220427802,1.2186757568701598,-0.08512327895039921,0.5135397178324697,0.5986629967828688,0.440918893170941,0.44817843658505 +lab043_prbs11_s_20260819_150001,47,566,0.9527883719797645,57,0.0114,15,42,0,-0.77925582093452,1.2195114632669486,-0.07687351461382276,0.5107387538112685,0.5876122684250913,0.44763737870702275,0.45475443575834257 +lab043_prbs11_s_20260819_150001,48,566,0.9518664435010262,62,0.0124,14,48,0,-0.7783404283607672,1.2204217439555674,-0.06906867002931712,0.4994613285213756,0.5685299985506928,0.4546916423280267,0.4616546617897829 +lab043_prbs11_s_20260819_150001,49,566,0.9508576924163983,72,0.0144,14,58,0,-0.7773511635914253,1.2214054843105613,-0.05629674040838482,0.4931608197836313,0.5494575601920161,0.4620785191923222,0.468878403074615 +lab043_prbs11_s_20260819_150001,50,566,0.9497648596757319,78,0.0156,14,64,0,-0.7762850265894883,1.2224656676156391,-0.050689975121733066,0.48599406701553116,0.5366840421372643,0.4697954141188885,0.47643312851931596 +lab043_prbs11_s_20260819_150001,51,566,0.9485792494521454,88,0.0176,14,74,0,-0.775140365164218,1.223603936835103,-0.04562944182554662,0.47328120406322627,0.5189106458887729,0.47784647975352407,0.48431759436615496 +lab043_prbs11_s_20260819_150001,52,566,0.9472985194803859,97,0.0194,14,83,0,-0.7739147108046895,1.2248227466948167,-0.03604306763158382,0.4656197206811681,0.5016627883127519,0.4862355831864577,0.49253834484945747 +lab043_prbs11_s_20260819_150001,53,566,0.9459214674097104,110,0.022,14,96,0,-0.7726045204213935,1.2261256204983921,-0.028934860414004268,0.4555293575257747,0.484464217939779,0.4949653251995523,0.5010965375959032 +lab043_prbs11_s_20260819_150001,54,566,0.9444299072269519,118,0.0236,14,104,0,-0.7712117829787346,1.2275105803885182,-0.010771779220566966,0.44505172451720554,0.45582350373777253,0.5040404385165715,0.5099976127503403 +lab043_prbs11_s_20260819_150001,55,566,0.9428237938254342,133,0.0266,14,119,0,-0.7697320033280102,1.2289820964113551,0.007944326875064455,0.4310884799767135,0.42314415310164905,0.5134690700893166,0.5192469264440223 +lab043_prbs11_s_20260819_150001,56,566,0.9410954530809613,140,0.028,14,126,0,-0.7681625976571363,1.2305427379500435,0.028681582782509336,0.4148199649948024,0.3861383822122931,0.523257648218952,0.5288548179893505 +lab043_prbs11_s_20260819_150001,57,566,0.9392327953708226,160,0.032,14,146,0,-0.766502124730572,1.2321939381917366,0.045324569668410475,0.4028496617967551,0.3575250921283446,0.533410421871228,0.5388275628478103 +lab043_prbs11_s_20260819_150001,58,566,0.9372277780499362,182,0.0364,14,166,2,-0.7647461494962304,1.233940107421578,0.06308869882462921,0.38464188531489907,0.32155318649026987,0.5439384678161941,0.5491740094009836 +lab043_prbs11_s_20260819_150001,59,566,0.9350687799899847,208,0.0416,14,190,4,-0.7628919510676594,1.2357839513316025,0.08100528965007356,0.36470302086761913,0.2836977312175456,0.5548534655775965,0.5599082999986138 +lab043_prbs11_s_20260819_150001,60,566,0.9327443199859539,226,0.0452,14,206,6,-0.760937347240449,1.2377276399399924,0.1012577624595449,0.3451497346460263,0.24389197218648137,0.5661615012640613,0.5710361850383204 +lab043_prbs11_s_20260819_150001,61,566,0.9302446752714513,257,0.0514,15,233,9,-0.7588775675796646,1.2397759170418416,0.12480081607671831,0.32533233181578325,0.20053151573906494,0.5778818486485496,0.5825750782437806 +lab043_prbs11_s_20260819_150001,62,566,0.9275544297174029,282,0.0564,15,253,14,-0.7567108273876054,1.2419305573684498,0.14412176059716675,0.30766171772831596,0.16353995713114922,0.5900226205324292,0.5945308609311352 +lab043_prbs11_s_20260819_150001,63,566,0.9246514106779686,318,0.0636,16,283,19,-0.7544299393021512,1.2441987081450885,0.1688674237970141,0.28574583861773284,0.11687841482071873,0.6025969006774378,0.60692399744975 +lab043_prbs11_s_20260819_150001,64,566,0.9215215507346269,341,0.0682,16,304,21,-0.752032528617333,1.2465827308165094,0.1967427271666566,0.2635390065950153,0.06679627942835867,0.6156268962052861,0.6197749322911085 +lab043_prbs11_s_20260819_150001,65,566,0.9181473473607321,377,0.0754,17,336,24,-0.749515726809513,1.2490854778874696,0.2247270301366536,0.24277603763329583,0.018049007496642233,0.6291284231597313,0.6330989394114357 +lab043_prbs11_s_20260819_150001,66,566,0.914504137423558,411,0.0822,17,365,29,-0.746873576757757,1.25171287321217,0.2518446829877077,0.22339974417102576,-0.02844493881668192,0.6431138750356846,0.6469095205552707 +lab043_prbs11_s_20260819_150001,67,566,0.9105699306000107,440,0.088,17,389,34,-0.744099924816224,1.2544710360722593,0.2766396532344156,0.20183391964058017,-0.07480573359383541,0.6576070005216411,0.6612273468777927 +lab043_prbs11_s_20260819_150001,68,566,0.9063119364892922,471,0.0942,18,413,40,-0.741191843252119,1.2573628778510053,0.3036198433195562,0.1793478462235617,-0.12427199709599451,0.6726281734214363,0.6760740509626956 +lab043_prbs11_s_20260819_150001,69,566,0.9017029953216349,502,0.1004,18,433,51,-0.7381372971009696,1.2604003663052592,0.33459628937394936,0.15257540746908455,-0.18202088190486482,0.6882046404020572,0.6914730782437374 +lab043_prbs11_s_20260819_150001,70,566,0.8967163958644219,531,0.1062,20,453,58,-0.7349337631357405,1.2635860105714392,0.3686413900601503,0.12716565214137263,-0.24147573791877766,0.7043524011037164,0.7074492926787098 +lab043_prbs11_s_20260819_150001,71,566,0.8913169925684403,565,0.113,21,474,70,-0.731580032150341,1.2669210131029909,0.4010611206373048,0.10120263276149781,-0.299858487875807,0.7211004009387844,0.7240321207946441 +lab043_prbs11_s_20260819_150001,72,566,0.8854523924108935,588,0.1176,20,492,76,-0.7280571104010204,1.2704242615756909,0.4317658952216323,0.07413265408944023,-0.3576332411321921,0.7384796537019096,0.741248429744683 +lab043_prbs11_s_20260819_150001,73,566,0.8790834822071036,630,0.126,20,517,93,-0.7243662003508955,1.2740945602414115,0.4687923249316298,0.04979677157776665,-0.4189955533538632,0.7565185664085023,0.7591229023776715 +lab043_prbs11_s_20260819_150001,74,566,0.8721729331668495,662,0.1324,21,531,110,-0.7204965303856242,1.2779426205618836,0.5098678507744039,0.021595480260885765,-0.48827237051351813,0.7752441534798072,0.7776879950620835 +lab043_prbs11_s_20260819_150001,75,566,0.8646588212026421,702,0.1404,22,548,132,-0.7164385414474577,1.2819779482135973,0.5468229478416348,-0.009571295502079283,-0.556394243343714,0.7946999032597145,0.7969801474058033 +lab043_prbs11_s_20260819_150001,76,566,0.8564858434579065,720,0.144,24,554,142,-0.7121845138355763,1.2862082197877576,0.5836443933467396,-0.041152795033487996,-0.6247971883802276,0.814910388808187,0.8170345594445259 +lab043_prbs11_s_20260819_150001,77,566,0.8475988930690542,743,0.1486,24,561,158,-0.7077194055223688,1.2906483933118205,0.6255258950275054,-0.07400983039163207,-0.6995357254191376,0.8359184510663351,0.8378914693353658 +lab043_prbs11_s_20260819_150001,78,566,0.8379100323979151,777,0.1554,23,571,183,-0.7030360735313533,1.2953055718732884,0.6667225824196384,-0.10009962213681328,-0.7668222045564517,0.857765079571197,0.859586487534297 +lab043_prbs11_s_20260819_150001,79,566,0.8273526129032499,800,0.16,23,578,199,-0.6981229130009546,1.3001912955279702,0.7103423737269061,-0.1305130501457721,-0.8408554238726782,0.8804887866585905,0.8821658890218503 +lab043_prbs11_s_20260819_150001,80,566,0.8158434957605584,832,0.1664,23,590,219,-0.6929630426074409,1.305322351328141,0.7550658065264927,-0.16212806496925453,-0.9171938714957473,0.904149153274888,0.9056833864922944 +lab043_prbs11_s_20260819_150001,81,566,0.8032890087238262,861,0.1722,23,601,237,-0.6875415561509107,1.3107135622320614,0.8041915297942587,-0.19768582073213492,-1.0018773505263936,0.9287892870352847,0.9301839300823902 +lab043_prbs11_s_20260819_150001,82,566,0.7896071809168755,880,0.176,23,603,254,-0.6818473519597151,1.3163759679156075,0.8509976720113029,-0.2375403497109131,-1.088538021722216,0.9544618741170124,0.95571801309178 +lab043_prbs11_s_20260819_150001,83,566,0.774684339745965,916,0.1832,24,609,283,-0.6758614631686461,1.3223284293261133,0.898336384524049,-0.27568731409566516,-1.1740236986197141,0.9812313518685594,0.9823510305970906 +lab043_prbs11_s_20260819_150001,84,566,0.7584198926348125,951,0.1902,26,614,311,-0.6695631207346078,1.328591599524779,0.9541712148690765,-0.31322339197499677,-1.2673946068440733,1.009156394082203,1.0101509245413738 +lab043_prbs11_s_20260819_150001,85,566,0.7406897684334062,990,0.198,26,618,346,-0.6629378194400956,1.3351799027267015,1.010133741444147,-0.3593032833320458,-1.3694370247761927,1.038300334223727,1.0391727420105303 +lab043_prbs11_s_20260819_150001,86,566,0.7213913840122179,1013,0.2026,26,620,367,-0.655960555331209,1.3421182032546042,1.0617877063185208,-0.40018839692219793,-1.4619761032407188,1.0687472365820885,1.0694985114308453 +lab043_prbs11_s_20260819_150001,87,566,0.7003926742172294,1045,0.209,26,621,398,-0.6486083218668584,1.349429379172685,1.1228940978237776,-0.4427343034084577,-1.5656284012322352,1.10056255582816,1.1012053241799082 +lab043_prbs11_s_20260819_150001,88,566,0.6775888055923391,1068,0.2136,26,621,421,-0.6408572912356061,1.357137125229212,1.1821911516374,-0.49039965418410864,-1.6725908058215087,1.1338410228524813,1.1343731587114927 +lab043_prbs11_s_20260819_150001,89,566,0.6528647867549533,1104,0.2208,26,623,455,-0.6326775677705234,1.365271170142834,1.2495833438721402,-0.540262928745818,-1.7898462726179583,1.168670080538643,1.1690963260685154 +lab043_prbs11_s_20260819_150001,90,566,0.6261130839288158,1129,0.2258,26,623,480,-0.6240400096949823,1.373860492951898,1.3209104503495315,-0.5899757492779695,-1.9108861996275008,1.2051430321043437,1.205471606346128 +lab043_prbs11_s_20260819_150001,91,566,0.5972726021460895,1161,0.2322,27,624,510,-0.6149043662600955,1.382945119630467,1.3890222635568863,-0.6498407127822906,-2.038862976339177,1.2433738453988161,1.24361230210792 +lab043_prbs11_s_20260819_150001,92,566,0.5662508132473159,1192,0.2384,28,626,538,-0.6052401251765805,1.392555392076102,1.4685311451839953,-0.7029132012574931,-2.1714443464414885,1.283480827772402,1.283637182552647 +lab043_prbs11_s_20260819_150001,93,566,0.5330246599990405,1218,0.2436,30,626,562,-0.5950032371136914,1.4027351136320565,1.548889170603502,-0.7617019655999068,-2.3105911362034086,1.3255899867524497,1.3256712978144956 +lab043_prbs11_s_20260819_150001,94,567,0.5025494495824687,1345,0.269,274,0,1070,-1.321267142967587,0.6805269296297578,1.2787028653956487,-1.925443302309483,-3.2041461677051317,1.5924547649840202,1.596154983731797 +lab043_prbs11_s_20260819_150001,95,567,0.47025505383773913,1345,0.269,273,0,1071,-1.3217891695288733,0.6800078182546943,1.2880275624270885,-1.9314698256008715,-3.21949738802796,1.5969898514810716,1.6006893561004114 +lab043_prbs11_s_20260819_150001,96,567,0.43597367761848066,1358,0.2716,270,0,1087,-1.3223713604423375,0.6794288785070813,1.2918665950510302,-1.9332373711498165,-3.2251039662008467,1.6025706346360753,1.6062557299886904 +lab043_prbs11_s_20260819_150001,97,567,0.39985966755633917,1372,0.2744,270,0,1101,-1.323013072999549,0.6787907495062326,1.2983998672302741,-1.9381998629561221,-3.2365997301863962,1.6092005646864826,1.612856422683389 +lab043_prbs11_s_20260819_150001,98,567,0.36200944908395427,1386,0.2772,269,1,1115,-1.3237110545661201,0.6780966657226418,1.3049875499976797,-1.942998867882373,-3.247986417880053,1.6168896946614815,1.6205130395478473 +lab043_prbs11_s_20260819_150001,99,567,0.3226504382602141,1406,0.2812,269,2,1134,-1.3244647360403263,0.6773471930799629,1.3115427129277317,-1.946354502421008,-3.2578972153487396,1.6256501925891491,1.6292301367967323 +lab043_prbs11_s_20260819_150001,100,567,0.2820366883351844,1430,0.286,268,3,1158,-1.325272152910284,0.6765442851195297,1.3208467598984652,-1.95705687809974,-3.277903637998205,1.635484963599418,1.6390091354186278 +lab043_prbs11_s_20260819_150001,101,565,0.2685923609492516,1060,0.212,35,0,1025,-0.6661000162900949,1.3320353647342618,1.1702326584406852,-0.4576350637979477,-1.627867722238633,1.1298947570686138,1.1171710614789336 +lab043_prbs11_s_20260819_150001,102,565,0.31592782949142206,1033,0.2066,35,0,998,-0.6719342771544538,1.326233684504965,1.1071540943472618,-0.4077967992086726,-1.5149508935559344,1.0931359661200968,1.0810759187052386 +lab043_prbs11_s_20260819_150001,103,565,0.3622848042869042,998,0.1996,35,0,963,-0.6775203816065186,1.320678774892282,1.0471869098717779,-0.3643410922593616,-1.4115280021311394,1.0577925411206943,1.0463800416139162 +lab043_prbs11_s_20260819_150001,104,565,0.4073118639785343,957,0.1914,35,0,922,-0.6828711451280178,1.315357891980795,0.9891036081549907,-0.32025411253735964,-1.3093577206923503,1.023797216477992,1.01301640152485 +lab043_prbs11_s_20260819_150001,105,565,0.4506567724580033,912,0.1824,35,0,877,-0.6880027544958235,1.310254939386483,0.9324634901449842,-0.2771000169076737,-1.2095635070526578,0.9910847781040989,0.9809224848731463 +lab043_prbs11_s_20260819_150001,106,565,0.4920561419226367,870,0.174,35,0,835,-0.6929189752480125,1.305366172599403,0.8774709402580847,-0.23475500521003717,-1.1122259454681218,0.9596133573800516,0.9500572107619774 +lab043_prbs11_s_20260819_150001,107,565,0.5313092401009925,812,0.1624,35,0,777,-0.6976311808903559,1.3006802816275789,0.823857859182045,-0.18915284188083611,-1.013010701062881,0.9293217299801961,0.9203611257479378 +lab043_prbs11_s_20260819_150001,108,565,0.5682847153673783,778,0.1556,35,0,743,-0.7021493942259088,1.2961872996389345,0.771964311162175,-0.14474311900283604,-0.9167074301650111,0.9001681116715154,0.8917889218840903 +lab043_prbs11_s_20260819_150001,109,565,0.6028558043459576,736,0.1472,35,0,701,-0.7064833964647679,1.2918775000452067,0.7212347870396306,-0.10526202747361407,-0.8264968145132447,0.8720990794150549,0.864298160345216 +lab043_prbs11_s_20260819_150001,110,565,0.6350325412563236,694,0.1388,35,0,659,-0.7106392106637852,1.2877448934244842,0.6717152879271698,-0.06630821781928292,-0.7380235057464528,0.8450815161959315,0.8378462947204817 +lab043_prbs11_s_20260819_150001,111,565,0.6648148975925997,655,0.131,35,0,620,-0.7146237435845483,1.28378261158505,0.6239786335995933,-0.02609615570631364,-0.6500747893059069,0.819076483814476,0.812401030062984 +lab043_prbs11_s_20260819_150001,112,565,0.692283095057272,618,0.1236,36,0,582,-0.7184489799103261,1.2799787367704656,0.5784300488728558,0.0120878509546873,-0.5663421979181684,0.7940504163236839,0.7879193380287711 +lab043_prbs11_s_20260819_150001,113,565,0.7175214202937549,583,0.1166,36,0,547,-0.7221174376673667,1.276330765016057,0.5312185503433392,0.04852161040924902,-0.4826969399340902,0.7699625416125242,0.7643743490684498 +lab043_prbs11_s_20260819_150001,114,565,0.7406202028432197,539,0.1078,36,0,503,-0.7256365617910249,1.2728312929616965,0.48580110702325685,0.08328697365298139,-0.4025141333702755,0.7467889015257883,0.7417327348324911 +lab043_prbs11_s_20260819_150001,115,565,0.7617206250622912,503,0.1006,35,0,468,-0.7290121252213998,1.2694745799054838,0.4414202479176482,0.11881573651500053,-0.3226045114026477,0.7245007490724401,0.719967571082296 +lab043_prbs11_s_20260819_150001,116,565,0.7809375681833145,467,0.0934,35,0,432,-0.7322477981738659,1.2662569761278624,0.40255241277828047,0.15427957332033052,-0.24827283945794995,0.7030679592821116,0.6990550710523581 +lab043_prbs11_s_20260819_150001,117,565,0.7984184582935019,441,0.0882,34,0,407,-0.7353543659838851,1.2631677565226058,0.3621592450864217,0.1836224632485949,-0.1785367818378268,0.682465996725849,0.678972771866944 +lab043_prbs11_s_20260819_150001,118,565,0.8142817206577293,402,0.0804,35,0,367,-0.7383314828065166,1.2602072650033327,0.32046093273529164,0.21226903676134937,-0.10819189597394227,0.662675131913582,0.6596980421847636 +lab043_prbs11_s_20260819_150001,119,565,0.8286673589169362,374,0.0748,35,0,339,-0.7411828412181719,1.2573718296143193,0.2820990784101022,0.24315676891329566,-0.03894230949680652,0.6436762008243364,0.6412080709418678 diff --git a/data/processed/lab043_diagnostics/report.txt b/data/processed/lab043_diagnostics/report.txt new file mode 100644 index 0000000..93698fb --- /dev/null +++ b/data/processed/lab043_diagnostics/report.txt @@ -0,0 +1,102 @@ +Lab043: офлайн-диагностика асимметрии BPSK 000/001 + +Исходные данные +- lab043_prbs11_s_20260819_134831: SHA-256 d573d9bd0332a0473be1ffe4ed149dea0281c4dbcc4c4c9c199642d072441c5a; корреляция маркера 0.941276; диагностическая остаточная CFO +1.183884 Гц. +- lab043_prbs11_s_20260819_150001: SHA-256 a8216afd7dd4cab7c3ba5190b5710a7a7e5409022ff2d8e595f2de9fb66fb98e; корреляция маркера 0.958288; диагностическая остаточная CFO -0.098485 Гц. + +Ошибки по тройкам после общей линейной фазовой коррекции по известной PRBS +- lab043_prbs11_s_20260819_134831: всего 34, в 000/001 34 (100.0%). + 000: n=618, ошибок=26, BER=0.042071, mean=-0.38301+0.02765j. + 001: n=626, ошибок=8, BER=0.012780, mean=-0.49129+0.01864j. + 010: n=625, ошибок=0, BER=0.000000, mean=+1.62468+0.02154j. + 011: n=623, ошибок=0, BER=0.000000, mean=+1.50789+0.02736j. + 100: n=626, ошибок=0, BER=0.000000, mean=-0.86278-0.29993j. + 101: n=622, ошибок=0, BER=0.000000, mean=-0.98532-0.29020j. + 110: n=624, ошибок=0, BER=0.000000, mean=+1.12176-0.30358j. + 111: n=634, ошибок=0, BER=0.000000, mean=+1.01780-0.28573j. +- lab043_prbs11_s_20260819_150001: всего 27, в 000/001 27 (100.0%). + 000: n=618, ошибок=22, BER=0.035599, mean=-0.46423-0.10343j. + 001: n=626, ошибок=5, BER=0.007987, mean=-0.60860-0.09103j. + 010: n=625, ошибок=0, BER=0.000000, mean=+1.54394-0.08669j. + 011: n=623, ошибок=0, BER=0.000000, mean=+1.38443-0.08533j. + 100: n=626, ошибок=0, BER=0.000000, mean=-0.96145-0.29963j. + 101: n=622, ошибок=0, BER=0.000000, mean=-1.10281-0.28653j. + 110: n=624, ошибок=0, BER=0.000000, mean=+1.04731-0.30446j. + 111: n=634, ошибок=0, BER=0.000000, mean=+0.88380-0.29881j. + +Цифровой TX и идеальный канал +- идеальные символы: ошибок 0, размах средних амплитуд по тройкам 0.000000000. +- после TX RRC: ошибок 0, размах средних амплитуд по тройкам 0.309839549. +- после масштаба 0.70: ошибок 0, размах средних амплитуд по тройкам 0.309839549. +- после complex64 и масштаба 2^14: ошибок 0, размах средних амплитуд по тройкам 0.309839551. +- Идеальный канал, без искажений: ошибок 0, из них 000/001 0. +- Идеальный канал, только SRO -3.5 ppm: ошибок 0, из них 000/001 0. +- Идеальный канал, только остаточная CFO +0.5 Гц: ошибок 0, из них 000/001 0. +- Идеальный канал, только дробный тайминг +0.45 отсчёта: ошибок 0, из них 000/001 0. +- Идеальный канал, только gain 0.8 exp(j0.4): ошибок 0, из них 000/001 0. +- Идеальный канал, SRO+CFO+тайминг+gain: ошибок 0, из них 000/001 0. + +RRC +{ + "tx_tap_count": 1201, + "rx_tap_count": 1201, + "rolloff": 0.35, + "span_symbols": 10, + "samples_per_symbol": 120, + "symmetry_max_abs_error": 0.0, + "tap_energy": 1.0, + "tx_group_delay_samples": 600, + "rx_group_delay_samples": 600, + "total_group_delay_samples": 1200, + "total_group_delay_symbols": 10.0, + "sampling_index_rule": "find_radio_frame: phase 0..119, затем одинаковый индекс phase+n*120 для обоих знаков", + "one_sample_sign_dependent_shift": false +} +h[kT]: h[-5T]=-6.851591991e-03, h[-4T]=+1.485224371e-03, h[-3T]=-7.515407088e-04, h[-2T]=+1.943271982e-04, h[-1T]=+1.181546870e-04, h[+0T]=+1.000000000e+00, h[+1T]=+1.181546870e-04, h[+2T]=+1.943271982e-04, h[+3T]=-7.515407088e-04, h[+4T]=+1.485224371e-03, h[+5T]=-6.851591991e-03 + +Фаза выборки +- lab043_prbs11_s_20260819_134831: выбранная фаза 18, ошибок 117 (000=60, 001=36, прочие=21), глаз=+0.54354; минимум обзора на фазе 10: 107 ошибок, глаз=+0.56936. Фаза не изменялась. +- lab043_prbs11_s_20260819_150001: выбранная фаза 34, ошибок 27 (000=23, 001=4, прочие=0), глаз=+0.69231; минимум обзора на фазе 29: 25 ошибок, глаз=+0.72067. Фаза не изменялась. + +DC, I/Q и расстояние до границы +- lab043_prbs11_s_20260819_134831: symbol DC=+0.31781-0.13594j; margin -1=0.68130, +1=1.31692; ellipse=1.721, corr(I,Q)=+0.495. +- lab043_prbs11_s_20260819_150001: symbol DC=+0.21442-0.19469j; margin -1=0.78498, +1=1.21382; ellipse=2.007, corr(I,Q)=+0.568. + +Серии одинаковых символов +- lab043_prbs11_s_20260819_134831, бит 0: 1: A=0.9858, BER=0.0000; 2: A=0.7443, BER=0.0000; 3: A=0.5819, BER=0.0127; 4: A=0.4875, BER=0.0169; 5: A=0.4550, BER=0.0341; 6: A=0.4022, BER=0.0370; 7+: A=0.3730, BER=0.0764 +- lab043_prbs11_s_20260819_134831, бит 1: 1: A=1.6247, BER=0.0000; 2: A=1.3734, BER=0.0000; 3: A=1.2430, BER=0.0000; 4: A=1.1186, BER=0.0000; 5: A=1.0757, BER=0.0000; 6: A=1.0384, BER=0.0000; 7+: A=1.0188, BER=0.0000 +- lab043_prbs11_s_20260819_150001, бит 0: 1: A=1.1031, BER=0.0000; 2: A=0.8891, BER=0.0000; 3: A=0.7035, BER=0.0000; 4: A=0.5777, BER=0.0101; 5: A=0.4900, BER=0.0195; 6: A=0.4621, BER=0.0370; 7+: A=0.3488, BER=0.1019 +- lab043_prbs11_s_20260819_150001, бит 1: 1: A=1.5439, BER=0.0000; 2: A=1.3163, BER=0.0000; 3: A=1.1365, BER=0.0000; 4: A=1.0012, BER=0.0000; 5: A=0.9210, BER=0.0000; 6: A=0.8581, BER=0.0000; 7+: A=0.8109, BER=0.0000 + +Низкие частоты +- чистый TX waveform: mean=+2.783e-04-1.588e-21j, P(|f|<500 Гц)=4.695284e-02, DC/median(2..10 кГц)=-5.79 дБ. +- lab043_prbs11_s_20260819_134831: RX после грубой CFO: mean=-3.587e-03-2.055e-03j, P(|f|<500 Гц)=2.128098e-02, DC/median(2..10 кГц)=-6.29 дБ. +- lab043_prbs11_s_20260819_150001: RX после грубой CFO: mean=-3.953e-03+1.590e-03j, P(|f|<500 Гц)=1.331852e-02, DC/median(2..10 кГц)=-8.34 дБ. +Передаточная характеристика на символьной скорости по известной PRBS +- lab043_prbs11_s_20260819_134831, 10..100 Гц: |H|=1.00207 (+0.02 дБ). +- lab043_prbs11_s_20260819_134831, 100..500 Гц: |H|=0.96610 (-0.30 дБ). +- lab043_prbs11_s_20260819_134831, 500..1000 Гц: |H|=0.91830 (-0.74 дБ). +- lab043_prbs11_s_20260819_134831, 1000..5000 Гц: |H|=0.97907 (-0.18 дБ). +- lab043_prbs11_s_20260819_134831, 5000..9000 Гц: |H|=1.18159 (+1.45 дБ). +- lab043_prbs11_s_20260819_150001, 10..100 Гц: |H|=0.84774 (-1.43 дБ). +- lab043_prbs11_s_20260819_150001, 100..500 Гц: |H|=0.81885 (-1.74 дБ). +- lab043_prbs11_s_20260819_150001, 500..1000 Гц: |H|=0.64034 (-3.87 дБ). +- lab043_prbs11_s_20260819_150001, 1000..5000 Гц: |H|=1.01586 (+0.14 дБ). +- lab043_prbs11_s_20260819_150001, 5000..9000 Гц: |H|=1.18464 (+1.47 дБ). +Диагностические модели символьного канала, обучение/проверка 50/50 +- lab043_prbs11_s_20260819_134831, gain: RMSE=0.61054, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_134831, gain+DC: RMSE=0.50147, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_134831, FIR: RMSE=0.40648, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_134831, FIR+DC: RMSE=0.20954, предсказано ошибок 7/2492 (0.002809). +- lab043_prbs11_s_20260819_150001, gain: RMSE=0.56869, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_150001, gain+DC: RMSE=0.48895, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_150001, FIR: RMSE=0.36009, предсказано ошибок 0/2492 (0.000000). +- lab043_prbs11_s_20260819_150001, FIR+DC: RMSE=0.21257, предсказано ошибок 1/2492 (0.000401). +- lab043_prbs11_s_20260819_134831: FIR+DC: DC=+0.31661-0.13179j, |h0|=0.99895, сумма |h[k!=0]|=1.17350. +- lab043_prbs11_s_20260819_150001: FIR+DC: DC=+0.21123-0.19622j, |h0|=0.99755, сумма |h[k!=0]|=1.19183. + +Интерпретация: таблицы являются измерениями; никакая коррекция DC, новый порог, эквалайзер или новая фаза автоматически не применялись. +Классификация: B. Ошибки 000/001 отсутствуют в полном цифровом TX-RRC -> RX-RRC тракте и появляются только в реальном IQ. Промежуточная зависимость амплитуды после одного TX-RRC полностью компенсируется согласованным RX-RRC в идеальном канале. +Наиболее вероятный механизм: совместное действие комплексного смещения середины кластеров и длинной памяти/ISI аппаратного тракта. FIR+DC-модель, обученная на первой половине и проверенная на второй, воспроизводит часть ошибок 000 без шума, но не весь измеренный BER; место внутри Pluto TX, кабеля/аттенюатора или RTL RX по этим данным не разделяется. +AC coupling отдельно не доказан: падение уровня длинных серий устойчиво, но оценённая низкочастотная АЧХ различается между двумя S и не образует однозначной монотонной высокочастотной характеристики. +Следующий диагностический шаг, пока не выполненный: сравнительный приём того же S другим приёмником либо loopback-разделение TX/RX с заранее фиксированными параметрами; после локализации отдельно проверить DC/АЧХ. diff --git a/data/processed/lab043_diagnostics/rrc_audit.json b/data/processed/lab043_diagnostics/rrc_audit.json new file mode 100644 index 0000000..51fc27d --- /dev/null +++ b/data/processed/lab043_diagnostics/rrc_audit.json @@ -0,0 +1,15 @@ +{ + "tx_tap_count": 1201, + "rx_tap_count": 1201, + "rolloff": 0.35, + "span_symbols": 10, + "samples_per_symbol": 120, + "symmetry_max_abs_error": 0.0, + "tap_energy": 1.0, + "tx_group_delay_samples": 600, + "rx_group_delay_samples": 600, + "total_group_delay_samples": 1200, + "total_group_delay_symbols": 10.0, + "sampling_index_rule": "find_radio_frame: phase 0..119, затем одинаковый индекс phase+n*120 для обоих знаков", + "one_sample_sign_dependent_shift": false +} diff --git a/data/processed/lab043_diagnostics/rrc_impulse_response.csv b/data/processed/lab043_diagnostics/rrc_impulse_response.csv new file mode 100644 index 0000000..64345a0 --- /dev/null +++ b/data/processed/lab043_diagnostics/rrc_impulse_response.csv @@ -0,0 +1,12 @@ +symbol_offset,sample_offset,h_normalized +-5,-600,-0.006851591990787818 +-4,-480,0.0014852243708439643 +-3,-360,-0.0007515407088124918 +-2,-240,0.00019432719820258287 +-1,-120,0.00011815468699953965 +0,0,1.0 +1,120,0.00011815468699953965 +2,240,0.00019432719820258287 +3,360,-0.0007515407088124918 +4,480,0.0014852243708439643 +5,600,-0.006851591990787818 diff --git a/data/processed/lab043_diagnostics/run_length_metrics.csv b/data/processed/lab043_diagnostics/run_length_metrics.csv new file mode 100644 index 0000000..17b75b6 --- /dev/null +++ b/data/processed/lab043_diagnostics/run_length_metrics.csv @@ -0,0 +1,73 @@ +capture,bit,run_length,position,symbol_count,mean_i,mean_q,mean_amplitude,mean_signed_amplitude,mean_phase_error_deg,ber +lab043_prbs11_s_20260819_134831,0,1,single,623,-0.9857821657455609,-0.2902642009959631,1.0700427503250372,0.9857821657455609,14.801727171202234,0.0 +lab043_prbs11_s_20260819_134831,0,2,first,315,-0.8424880653546357,-0.3127137567212283,0.9418657994085868,0.8424880653546357,18.417261446285792,0.0 +lab043_prbs11_s_20260819_134831,0,2,last,315,-0.6461882928041996,-0.17139447420495169,0.7167473943757303,0.6461882928041996,14.794830864298815,0.0 +lab043_prbs11_s_20260819_134831,0,3,first,158,-0.882306370843142,-0.28916668828632286,0.973149555801682,0.882306370843142,16.33931198629656,0.0 +lab043_prbs11_s_20260819_134831,0,3,last,158,-0.3492240321328001,0.057379287291675365,0.40072110509646086,0.3492240321328001,-6.520976368528217,0.0379746835443038 +lab043_prbs11_s_20260819_134831,0,3,middle,158,-0.5142377260197933,-0.14401977724709963,0.5907059677315208,0.5142377260197933,13.41558947498171,0.0 +lab043_prbs11_s_20260819_134831,0,4,first,74,-0.8681869519253246,-0.2586254758947878,0.956311205223434,0.8681869519253246,14.328539173615859,0.0 +lab043_prbs11_s_20260819_134831,0,4,last,74,-0.28305858758162084,0.26805526249921724,0.4099239931477214,0.28305858758162084,-42.86659221652123,0.0 +lab043_prbs11_s_20260819_134831,0,4,middle,148,-0.39939338300128047,-0.03734529076885137,0.4588271089871034,0.39939338300128047,3.7409042801562378,0.033783783783783786 +lab043_prbs11_s_20260819_134831,0,5,first,41,-0.9135417256105938,-0.2939747998048404,0.9999210129840694,0.9135417256105938,14.653322628302856,0.0 +lab043_prbs11_s_20260819_134831,0,5,last,41,-0.30510111587274596,0.4229234429158172,0.5445408933903639,0.30510111587274596,-56.76960088975864,0.04878048780487805 +lab043_prbs11_s_20260819_134831,0,5,middle,123,-0.35217660805637374,0.02944583444411268,0.454499837173404,0.35217660805637374,-13.184961796821467,0.04065040650406504 +lab043_prbs11_s_20260819_134831,0,6,first,18,-0.887779527002307,-0.24575785543978,0.9537807208800194,0.887779527002307,11.871907726952344,0.0 +lab043_prbs11_s_20260819_134831,0,6,last,18,-0.3067248634241106,0.47784545858497496,0.5779879336106305,0.3067248634241106,-57.00687683367144,0.0 +lab043_prbs11_s_20260819_134831,0,6,middle,72,-0.3046959705110195,0.12883217311611908,0.44063343806980376,0.3046959705110195,-28.470653508668384,0.05555555555555555 +lab043_prbs11_s_20260819_134831,0,7+,first,20,-0.8814991399013437,-0.3972899016812731,1.0127782355320558,0.8814991399013437,23.660468599173413,0.0 +lab043_prbs11_s_20260819_134831,0,7+,last,20,-0.4922350130875081,0.5406233334931604,0.7469228959424001,0.4922350130875081,-48.093653513028514,0.0 +lab043_prbs11_s_20260819_134831,0,7+,middle,117,-0.26568296607835235,0.27751684780162145,0.5085135965117819,0.26568296607835235,-45.13165326531156,0.10256410256410256 +lab043_prbs11_s_20260819_134831,1,1,single,625,1.6246814910527565,0.021540864870758254,1.6592362427400138,1.6246814910527565,0.18194257569663422,0.0 +lab043_prbs11_s_20260819_134831,1,2,first,313,1.4651935224517354,0.024510525512766227,1.4983324416844146,1.4651935224517354,0.417236057931388,0.0 +lab043_prbs11_s_20260819_134831,1,2,last,313,1.2815513430080545,-0.12371400810134757,1.31932334145145,1.2815513430080545,-5.072518132084281,0.0 +lab043_prbs11_s_20260819_134831,1,3,first,149,1.5588528736675464,0.021913572651993535,1.5946277676975151,1.5588528736675464,0.31644838639122885,0.0 +lab043_prbs11_s_20260819_134831,1,3,last,149,0.9981815034652967,-0.2895061746626435,1.0577041982967457,0.9981815034652967,-15.912301681602749,0.0 +lab043_prbs11_s_20260819_134831,1,3,middle,149,1.1720129617959687,-0.09130967883448256,1.2115117901079218,1.1720129617959687,-3.7933594981412506,0.0 +lab043_prbs11_s_20260819_134831,1,4,first,82,1.5458958865581518,0.05902875919159914,1.5794796801436435,1.5458958865581518,1.6175355845057795,0.0 +lab043_prbs11_s_20260819_134831,1,4,last,82,0.8835847577728094,-0.5578416198790078,1.058918110662329,0.8835847577728094,-32.797608429623544,0.0 +lab043_prbs11_s_20260819_134831,1,4,middle,164,1.0225109228188605,-0.18103130808090823,1.0786125736379992,1.0225109228188605,-11.136408227843665,0.0 +lab043_prbs11_s_20260819_134831,1,5,first,41,1.5704002752244177,0.002849185839505849,1.5942763755858251,1.5704002752244177,0.02071737447490966,0.0 +lab043_prbs11_s_20260819_134831,1,5,last,41,0.8599842081274885,-0.697398946475133,1.1186316858583556,0.8599842081274885,-38.976071958325626,0.0 +lab043_prbs11_s_20260819_134831,1,5,middle,123,0.982634383971298,-0.30232336867806897,1.0617197160088647,0.982634383971298,-18.81837795079926,0.0 +lab043_prbs11_s_20260819_134831,1,6,first,18,1.55212416200691,0.025991126155437902,1.5858249348750895,1.55212416200691,0.2844874106828473,0.0 +lab043_prbs11_s_20260819_134831,1,6,last,18,1.0883355189530914,-0.8137221339620337,1.369970248215814,1.0883355189530914,-37.18531591150748,0.0 +lab043_prbs11_s_20260819_134831,1,6,middle,72,0.8975420383538609,-0.439309514136695,1.0585666107585348,0.8975420383538609,-26.706624685248492,0.0 +lab043_prbs11_s_20260819_134831,1,7+,first,21,1.4694166646555065,0.0438076034442234,1.495818153253665,1.4694166646555065,0.9294249817155132,0.0 +lab043_prbs11_s_20260819_134831,1,7+,last,21,1.0867985672903595,-0.885233809219734,1.4103358099926386,1.0867985672903595,-39.160112160969845,0.0 +lab043_prbs11_s_20260819_134831,1,7+,middle,126,0.9323341129687039,-0.5479331057828538,1.1194144515071809,0.9323341129687039,-30.012822203600166,0.0 +lab043_prbs11_s_20260819_150001,0,1,single,623,-1.1030631876442467,-0.2861020577882339,1.1650773302010677,1.1030631876442467,12.83074671663554,0.0 +lab043_prbs11_s_20260819_150001,0,2,first,315,-0.9438306684637361,-0.30290467896809664,1.0192264897855752,0.9438306684637361,15.706636499280913,0.0 +lab043_prbs11_s_20260819_150001,0,2,last,315,-0.8343812207234607,-0.21949290620454157,0.8996211841012238,0.8343812207234607,13.392275852320203,0.0 +lab043_prbs11_s_20260819_150001,0,3,first,158,-0.9801436180086766,-0.29265827252392085,1.0499381278451252,0.9801436180086766,14.359839568634152,0.0 +lab043_prbs11_s_20260819_150001,0,3,last,158,-0.4810387576529684,-0.07979229875331385,0.528607394001191,0.4810387576529684,8.708559121488143,0.0 +lab043_prbs11_s_20260819_150001,0,3,middle,158,-0.6494318615630686,-0.2267518206305464,0.7272569010770391,0.6494318615630686,16.78465355635992,0.0 +lab043_prbs11_s_20260819_150001,0,4,first,74,-0.951273564103175,-0.29611767886435725,1.0181457876048636,0.951273564103175,15.649578402390466,0.0 +lab043_prbs11_s_20260819_150001,0,4,last,74,-0.3276292509285005,0.04048671315428493,0.37534513560086696,0.3276292509285005,-4.1654082139444775,0.013513513513513514 +lab043_prbs11_s_20260819_150001,0,4,middle,148,-0.5160413085051206,-0.1724948748013655,0.5909819060423562,0.5160413085051206,14.99806210281587,0.013513513513513514 +lab043_prbs11_s_20260819_150001,0,5,first,41,-0.968615319878473,-0.3083089234675125,1.038542938161848,0.968615319878473,16.447598165908122,0.0 +lab043_prbs11_s_20260819_150001,0,5,last,41,-0.254015644856774,0.19547676549322335,0.3445906280679367,0.254015644856774,-35.989364114243536,0.0 +lab043_prbs11_s_20260819_150001,0,5,middle,123,-0.40917042039516416,-0.09151761672201279,0.48029020650161963,0.40917042039516416,6.016051997727609,0.032520325203252036 +lab043_prbs11_s_20260819_150001,0,6,first,18,-1.054071931279121,-0.3014826421075699,1.1223306809126927,1.054071931279121,16.559202493369735,0.0 +lab043_prbs11_s_20260819_150001,0,6,last,18,-0.1964767498254503,0.3121613520798808,0.39326051470355733,0.1964767498254503,-58.854030063897085,0.1111111111111111 +lab043_prbs11_s_20260819_150001,0,6,middle,72,-0.38045768497936,-0.01685802762084246,0.4630687400210571,0.38045768497936,-9.097631382178173,0.027777777777777776 +lab043_prbs11_s_20260819_150001,0,7+,first,20,-1.030782900110509,-0.29681326450105644,1.1567757008936768,1.030782900110509,13.016420908040049,0.0 +lab043_prbs11_s_20260819_150001,0,7+,last,20,-0.19775877228665736,0.4066703288523025,0.4695654428461916,0.19775877228665736,-65.86574671145465,0.1 +lab043_prbs11_s_20260819_150001,0,7+,middle,117,-0.25801692584002206,0.08467032676227261,0.4130944817717871,0.25801692584002206,-28.80910874944147,0.11965811965811966 +lab043_prbs11_s_20260819_150001,1,1,single,625,1.543937892189543,-0.0866919401911572,1.5765627581653052,1.543937892189543,-4.197559508608589,0.0 +lab043_prbs11_s_20260819_150001,1,2,first,313,1.361786115921763,-0.08037829281320101,1.3962668544987367,1.361786115921763,-4.499258071458339,0.0 +lab043_prbs11_s_20260819_150001,1,2,last,313,1.270744473611291,-0.15941431941910755,1.3133222129137216,1.270744473611291,-7.198651280005335,0.0 +lab043_prbs11_s_20260819_150001,1,3,first,149,1.420701086437231,-0.08069079715052878,1.4555679094941192,1.420701086437231,-4.425965875615329,0.0 +lab043_prbs11_s_20260819_150001,1,3,last,149,0.9143215528566313,-0.3093291346767263,0.9880459649748297,0.9143215528566313,-18.18464411245232,0.0 +lab043_prbs11_s_20260819_150001,1,3,middle,149,1.0744085994719226,-0.15201002276221748,1.1240677574519422,1.0744085994719226,-8.34370851095225,0.0 +lab043_prbs11_s_20260819_150001,1,4,first,82,1.3918047176779145,-0.08904540465613667,1.425272130524323,1.3918047176779145,-4.88602724735577,0.0 +lab043_prbs11_s_20260819_150001,1,4,last,82,0.7875213220983199,-0.47287476298854797,0.9352841509128977,0.7875213220983199,-30.492329099374093,0.0 +lab043_prbs11_s_20260819_150001,1,4,middle,164,0.9126815803354904,-0.23463367195779292,0.9864357488415159,0.9126815803354904,-15.559682234393858,0.0 +lab043_prbs11_s_20260819_150001,1,5,first,41,1.3859855823571308,-0.0949080079579381,1.4173158678640236,1.3859855823571308,-5.194550123757227,0.0 +lab043_prbs11_s_20260819_150001,1,5,last,41,0.7039054907082286,-0.596054863193749,0.9395363518554335,0.7039054907082286,-40.17907710629712,0.0 +lab043_prbs11_s_20260819_150001,1,5,middle,123,0.8384428615261585,-0.3160958338842525,0.9429614055898407,0.8384428615261585,-22.708951504218277,0.0 +lab043_prbs11_s_20260819_150001,1,6,first,18,1.381288694941642,-0.14096462974809634,1.4175256957656206,1.381288694941642,-7.0278376400009765,0.0 +lab043_prbs11_s_20260819_150001,1,6,last,18,0.6725517345139995,-0.7065254272538648,0.9908845683950595,0.6725517345139995,-46.78411967919625,0.0 +lab043_prbs11_s_20260819_150001,1,6,middle,72,0.7736538067078175,-0.37490704027314653,0.9051695274003921,0.7736538067078175,-28.23304732736698,0.0 +lab043_prbs11_s_20260819_150001,1,7+,first,21,1.4521311301406687,-0.10451377197395277,1.4735631909026754,1.4521311301406687,-5.522381290059382,0.0 +lab043_prbs11_s_20260819_150001,1,7+,last,21,0.6666411083852714,-0.8603178177571177,1.1030411950063679,0.6666411083852714,-52.88754280866333,0.0 +lab043_prbs11_s_20260819_150001,1,7+,middle,126,0.7280475555396544,-0.49556558083947105,0.9433044420725241,0.7280475555396544,-36.041327810425955,0.0 diff --git a/data/processed/lab043_diagnostics/spectrum_metrics.csv b/data/processed/lab043_diagnostics/spectrum_metrics.csv new file mode 100644 index 0000000..b683fb1 --- /dev/null +++ b/data/processed/lab043_diagnostics/spectrum_metrics.csv @@ -0,0 +1,4 @@ +source,mean_i,mean_q,mean_amplitude,dc_bin_to_2_10khz_median_db,power_fraction_abs_f_below_100_hz,power_fraction_abs_f_below_500_hz,power_fraction_abs_f_below_1000_hz,power_fraction_abs_f_below_5000_hz,power_fraction_abs_f_below_20000_hz +чистый TX waveform,0.0002782869136427176,-1.5879563804290438e-21,0.0588630600980395,-5.7877751031530575,0.005343323060338315,0.046952843076681544,0.09836002169740929,0.4978957206724244,0.9999895817349141 +lab043_prbs11_s_20260819_134831: RX после грубой CFO,-0.0035866184049641804,-0.0020547927693326117,0.01810488205985558,-6.288525799773844,0.00309223994088002,0.02128098077879969,0.04279840801293315,0.2435579653253578,0.5894197180187851 +lab043_prbs11_s_20260819_150001: RX после грубой CFO,-0.003953464860272102,0.0015903730102091952,0.019498397032834895,-8.342481966309766,0.0016061181880257529,0.01331852069411624,0.026415827000324466,0.20593062957796177,0.5104841300396246 diff --git a/data/processed/lab043_diagnostics/symbol_channel_coefficients.csv b/data/processed/lab043_diagnostics/symbol_channel_coefficients.csv new file mode 100644 index 0000000..8c2422d --- /dev/null +++ b/data/processed/lab043_diagnostics/symbol_channel_coefficients.csv @@ -0,0 +1,77 @@ +capture,model,term,lag_symbols,coefficient_i,coefficient_q,magnitude +lab043_prbs11_s_20260819_134831,gain,gain,0,1.000076898152648,-0.0025876863086384183,1.0000802459498206 +lab043_prbs11_s_20260819_134831,gain+DC,gain,0,0.9980502284607532,-0.001713532962643461,0.9980516994253232 +lab043_prbs11_s_20260819_134831,gain+DC,DC,,0.31565380451262254,-0.1361493836387165,0.3437644236515689 +lab043_prbs11_s_20260819_134831,FIR,tap,-8,0.005088142049709876,0.002193127516113645,0.005540667633052954 +lab043_prbs11_s_20260819_134831,FIR,tap,-7,0.008250747337673559,-0.00010273657343665143,0.008251386940002802 +lab043_prbs11_s_20260819_134831,FIR,tap,-6,0.00021698735210965038,-0.0016766140662735453,0.0016905970064453177 +lab043_prbs11_s_20260819_134831,FIR,tap,-5,-0.007805718933475608,-0.003652166929281369,0.008617863502502003 +lab043_prbs11_s_20260819_134831,FIR,tap,-4,0.00018008620290619237,-0.0009846550370057607,0.0010009878033113023 +lab043_prbs11_s_20260819_134831,FIR,tap,-3,-0.0012544616086486002,-0.0022538672986419602,0.002579455703721396 +lab043_prbs11_s_20260819_134831,FIR,tap,-2,0.02772783038337061,-0.0009823298651181317,0.02774522571061323 +lab043_prbs11_s_20260819_134831,FIR,tap,-1,-0.05106409144999977,0.0050049066247871326,0.051308776305197326 +lab043_prbs11_s_20260819_134831,FIR,tap,0,1.0009908581405378,-0.003747165757517026,1.0009978717920156 +lab043_prbs11_s_20260819_134831,FIR,tap,1,-0.2501253779258194,-0.1577786617682146,0.2957309770583776 +lab043_prbs11_s_20260819_134831,FIR,tap,2,-0.15345632642043275,-0.19209445398806702,0.24586403431862092 +lab043_prbs11_s_20260819_134831,FIR,tap,3,-0.011209673614077015,-0.1680166432702729,0.16839016954188352 +lab043_prbs11_s_20260819_134831,FIR,tap,4,0.05438796195617884,-0.11262761701550482,0.1250721012869696 +lab043_prbs11_s_20260819_134831,FIR,tap,5,0.0704524188217869,-0.05119252826538461,0.08708741739219665 +lab043_prbs11_s_20260819_134831,FIR,tap,6,0.06749176660362004,-0.008556822484033881,0.0680320348828461 +lab043_prbs11_s_20260819_134831,FIR,tap,7,0.04562068583497904,0.01402124330933205,0.04772674554160744 +lab043_prbs11_s_20260819_134831,FIR,tap,8,0.027936682659462142,0.024898426674665082,0.03742178361448271 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-8,0.002716787446301202,0.00318023458001113,0.004182681677139485 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-7,0.005905068022079525,0.0008736828194000068,0.0059693508871820344 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-6,-0.0021372593533339457,-0.0006966283864597209,0.002247925410736511 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-5,-0.010146468930157675,-0.0026777994292784754,0.010493876382638552 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-4,-0.001954784960822691,-9.598726745357295e-05,0.00195714020922666 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-3,-0.0033891688953710014,-0.001365267745016097,0.003653822904429227 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-2,0.02563805541154798,-0.00011243396946499353,0.025638301946172143 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,-1,-0.05311998118867498,0.005860697353311943,0.05344230697633011 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,0,0.9989459679181133,-0.0028959537200805765,0.998950165607777 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,1,-0.25216693291386993,-0.15692883806527905,0.2970098016424824 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,2,-0.1555249288499998,-0.19123337143732255,0.24649179670951013 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,3,-0.013561760337292937,-0.16703755671103424,0.16758719132272057 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,4,0.05204131643521074,-0.11165079542716785,0.12318359767367172 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,5,0.0678962612965066,-0.05012849459196749,0.08439649440646461 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,6,0.06491527875856577,-0.007484326051723186,0.0653453024535873 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,7,0.04304879842471665,0.015091824751082223,0.045617564820245114 +lab043_prbs11_s_20260819_134831,FIR+DC,tap,8,0.02532621180069811,0.025985068976529167,0.036285545522822156 +lab043_prbs11_s_20260819_134831,FIR+DC,DC,,0.31661026628081473,-0.13179312360078585,0.34294531363303454 +lab043_prbs11_s_20260819_150001,gain,gain,0,0.998698652150514,-0.0020742102322665553,0.9987008061253085 +lab043_prbs11_s_20260819_150001,gain+DC,gain,0,0.9973674020267097,-0.000782092951252052,0.9973677086686198 +lab043_prbs11_s_20260819_150001,gain+DC,DC,,0.20734220678251997,-0.2012472665180131,0.28894853000909604 +lab043_prbs11_s_20260819_150001,FIR,tap,-8,-0.0038344808885037744,-0.0019628856870946626,0.00430768660708991 +lab043_prbs11_s_20260819_150001,FIR,tap,-7,0.0012385017770719237,-0.003639789216896658,0.003844730445069914 +lab043_prbs11_s_20260819_150001,FIR,tap,-6,0.0023998700886108007,-0.0006957724557815977,0.0024986948097823324 +lab043_prbs11_s_20260819_150001,FIR,tap,-5,-0.0009233996208636567,-0.0027768283867714615,0.0029263360622783815 +lab043_prbs11_s_20260819_150001,FIR,tap,-4,0.009234526465185616,0.0006637967162348613,0.009258353261606393 +lab043_prbs11_s_20260819_150001,FIR,tap,-3,-0.0061831798377780505,-0.0007957137093386642,0.0062341698094882285 +lab043_prbs11_s_20260819_150001,FIR,tap,-2,0.027049804353416473,-0.003290927494980278,0.027249259060299715 +lab043_prbs11_s_20260819_150001,FIR,tap,-1,-0.07147738202589815,0.003953379177205796,0.0715866282778771 +lab043_prbs11_s_20260819_150001,FIR,tap,0,0.9989112753555566,-0.0028005479232093332,0.9989152011563017 +lab043_prbs11_s_20260819_150001,FIR,tap,1,-0.25722926274231,-0.10195801621504566,0.2766989893032858 +lab043_prbs11_s_20260819_150001,FIR,tap,2,-0.22017259736636305,-0.13190843406629513,0.2566628286465976 +lab043_prbs11_s_20260819_150001,FIR,tap,3,-0.0930284091519903,-0.1357719406157327,0.16458525076054706 +lab043_prbs11_s_20260819_150001,FIR,tap,4,-0.03876729891852838,-0.11817648198431167,0.12437276373718996 +lab043_prbs11_s_20260819_150001,FIR,tap,5,-0.01130815600869626,-0.09106010576263912,0.09175956219283109 +lab043_prbs11_s_20260819_150001,FIR,tap,6,0.025146655600306916,-0.06652125676965408,0.071115623389623 +lab043_prbs11_s_20260819_150001,FIR,tap,7,0.031132710714738852,-0.04451342716715576,0.05432026210000552 +lab043_prbs11_s_20260819_150001,FIR,tap,8,0.02677074073353095,-0.021288542617737702,0.03420342974336298 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-8,-0.005416578965875615,-0.0004932630575657877,0.005438992198700536 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-7,-0.0003264665030823094,-0.00218607858295794,0.002210321231971087 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-6,0.0008291858978162321,0.000763247729913492,0.001126985514705229 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-5,-0.0024850792043536376,-0.0013261726475371307,0.0028167982787176207 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-4,0.00781020318653478,0.001986861165911015,0.008058963401552542 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-3,-0.007607393782508261,0.0005272491792477566,0.007625643045616729 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-2,0.02565556792824977,-0.001995810946489221,0.025733080403542136 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,-1,-0.07284901121968092,0.00522749570091397,0.07303632758421161 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,0,0.9975469847156482,-0.00153324823658316,0.9975481630304557 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,1,-0.2585913282118942,-0.1006927835056243,0.2775040750628756 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,2,-0.22155270809377556,-0.1306264389831378,0.25719422432298344 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,3,-0.09459765226614196,-0.1343142590568109,0.16428340147515216 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,4,-0.040332911822503086,-0.11672217255452778,0.12349416723850065 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,5,-0.013013549162631055,-0.08947595342270917,0.0904173584148035 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,6,0.023427698654697245,-0.06492450492311219,0.06902208634753461 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,7,0.029416823043976935,-0.04291952639276798,0.05203302051370994 +lab043_prbs11_s_20260819_150001,FIR+DC,tap,8,0.02502911132022019,-0.019670730147915552,0.03183385050590155 +lab043_prbs11_s_20260819_150001,FIR+DC,DC,,0.21123306182846485,-0.19621595665140998,0.2883055810317519 diff --git a/data/processed/lab043_diagnostics/symbol_channel_models.csv b/data/processed/lab043_diagnostics/symbol_channel_models.csv new file mode 100644 index 0000000..6e5525d --- /dev/null +++ b/data/processed/lab043_diagnostics/symbol_channel_models.csv @@ -0,0 +1,9 @@ +capture,model,training_symbols,validation_symbols,validation_rmse,predicted_bit_errors,predicted_ber,predicted_mean_i,predicted_mean_q +lab043_prbs11_s_20260819_134831,gain,2492,2492,0.610543736145253,0,0.0,-0.003210519737247668,8.307179160958007e-06 +lab043_prbs11_s_20260819_134831,gain+DC,2492,2492,0.5014741666559047,0,0.0,0.3124497909381097,-0.1361438827303292 +lab043_prbs11_s_20260819_134831,FIR,2492,2492,0.4064837326783923,0,0.0,-0.0026407799641849634,0.0011360028841420013 +lab043_prbs11_s_20260819_134831,FIR+DC,2492,2492,0.20953511049382884,7,0.0028089887640449437,0.3140884092399191,-0.1307066239235744 +lab043_prbs11_s_20260819_150001,gain,2492,2492,0.5686850981096296,0,0.0,-0.003206095191494428,6.658780841947204e-06 +lab043_prbs11_s_20260819_150001,gain+DC,2492,2492,0.48894584689725634,0,0.0,0.2041403852671855,-0.2012447557862274 +lab043_prbs11_s_20260819_150001,FIR,2492,2492,0.3600912162373556,0,0.0,-0.0019963832312459326,0.0011631487049894307 +lab043_prbs11_s_20260819_150001,FIR+DC,2492,2492,0.21256610617464475,1,0.0004012841091492777,0.20931602047549436,-0.1951265092051798 diff --git a/data/processed/lab043_diagnostics/symbol_channel_triplets.csv b/data/processed/lab043_diagnostics/symbol_channel_triplets.csv new file mode 100644 index 0000000..d6b8b62 --- /dev/null +++ b/data/processed/lab043_diagnostics/symbol_channel_triplets.csv @@ -0,0 +1,65 @@ +source,triplet,count,bit_errors,ber,mean_i,mean_q,median_i,median_q,std_i,std_q,mean_amplitude,mean_phase_error_deg,mean_distance_to_ideal,decision_cross_fraction,raw_mean_i,raw_mean_q,fitted_gain_i,fitted_gain_q +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",000,309,0,0.0,-1.0000768981526478,0.002587686308638419,-1.000076898152648,0.0025876863086384183,2.220446049250313e-16,8.673617379884035e-19,1.0000802459498206,-0.14825177302342232,0.002588828645892848,0.0,-1.0000768981526478,0.002587686308638419,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",001,312,0,0.0,-1.0000768981526478,0.002587686308638419,-1.000076898152648,0.0025876863086384183,2.220446049250313e-16,8.673617379884035e-19,1.0000802459498208,-0.14825177302342235,0.002588828645892848,0.0,-1.0000768981526478,0.002587686308638419,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",010,320,0,0.0,1.0000768981526478,-0.0025876863086384187,1.000076898152648,-0.0025876863086384183,2.220446049250313e-16,4.336808689942018e-19,1.0000802459498208,-0.14825177302342232,0.002588828645892848,0.0,1.0000768981526478,-0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",011,307,0,0.0,1.0000768981526478,-0.0025876863086384187,1.000076898152648,-0.0025876863086384183,2.220446049250313e-16,4.336808689942018e-19,1.0000802459498208,-0.14825177302342232,0.002588828645892848,0.0,1.0000768981526478,-0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",100,313,0,0.0,-1.0000768981526478,0.0025876863086384187,-1.000076898152648,0.0025876863086384183,2.220446049250313e-16,4.336808689942018e-19,1.0000802459498208,-0.14825177302342235,0.002588828645892848,0.0,-1.0000768981526478,0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",101,315,0,0.0,-1.000076898152648,0.0025876863086384187,-1.000076898152648,0.0025876863086384183,0.0,4.336808689942018e-19,1.0000802459498208,-0.14825177302342235,0.002588828645892848,0.0,-1.000076898152648,0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",110,308,0,0.0,1.000076898152648,-0.0025876863086384187,1.000076898152648,-0.0025876863086384183,0.0,4.336808689942018e-19,1.0000802459498208,-0.14825177302342232,0.002588828645892848,0.0,1.000076898152648,-0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain, проверочная половина",111,306,0,0.0,1.0000768981526478,-0.0025876863086384187,1.000076898152648,-0.0025876863086384183,2.220446049250313e-16,4.336808689942018e-19,1.0000802459498208,-0.14825177302342232,0.002588828645892848,0.0,1.0000768981526478,-0.0025876863086384187,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",000,309,0,0.0,-0.6823964239481307,-0.13443585067607303,-0.6823964239481306,-0.13443585067607305,1.1102230246251565e-16,2.7755575615628914e-17,0.6955126723246644,11.144864752908862,0.34488408120401126,0.0,-0.6823964239481307,-0.13443585067607303,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",001,312,0,0.0,-0.6823964239481306,-0.13443585067607303,-0.6823964239481306,-0.13443585067607305,0.0,2.7755575615628914e-17,0.6955126723246644,11.14486475290886,0.3448840812040112,0.0,-0.6823964239481306,-0.13443585067607303,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",010,320,0,0.0,1.313704032973376,-0.13786291660135994,1.3137040329733758,-0.13786291660135996,2.220446049250313e-16,2.7755575615628914e-17,1.3209180406158232,-5.990814121300027,0.34266077113902954,0.0,1.313704032973376,-0.13786291660135994,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",011,307,0,0.0,1.3137040329733758,-0.13786291660135996,1.3137040329733758,-0.13786291660135996,0.0,0.0,1.3209180406158232,-5.990814121300028,0.34266077113902954,0.0,1.3137040329733758,-0.13786291660135996,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",100,313,0,0.0,-0.6823964239481306,-0.13443585067607303,-0.6823964239481306,-0.13443585067607305,0.0,2.7755575615628914e-17,0.6955126723246644,11.144864752908859,0.3448840812040112,0.0,-0.6823964239481306,-0.13443585067607303,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",101,315,0,0.0,-0.6823964239481307,-0.13443585067607303,-0.6823964239481306,-0.13443585067607305,1.1102230246251565e-16,2.7755575615628914e-17,0.6955126723246644,11.14486475290886,0.3448840812040112,0.0,-0.6823964239481307,-0.13443585067607303,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",110,308,0,0.0,1.313704032973376,-0.13786291660135996,1.3137040329733758,-0.13786291660135996,2.220446049250313e-16,0.0,1.3209180406158232,-5.990814121300028,0.34266077113902954,0.0,1.313704032973376,-0.13786291660135996,1.0,0.0 +"lab043_prbs11_s_20260819_134831: gain+DC, проверочная половина",111,306,0,0.0,1.313704032973376,-0.13786291660135994,1.3137040329733758,-0.13786291660135996,2.220446049250313e-16,2.7755575615628914e-17,1.3209180406158232,-5.990814121300028,0.34266077113902954,0.0,1.313704032973376,-0.13786291660135994,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",000,309,0,0.0,-0.6959052312437318,0.15331107269225944,-0.6990093630386194,0.15918089308300562,0.20189181448009522,0.2780541085757557,0.7693007644568678,-13.65671147578478,0.4339815625407696,0.0,-0.6959052312437318,0.15331107269225944,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",001,312,0,0.0,-0.8058528221696036,0.1729192675974602,-0.8077464972367241,0.1750171073523559,0.19821143850994494,0.28327343165066554,0.8753009723070693,-13.019900843048168,0.38228688158698926,0.0,-0.8058528221696036,0.1729192675974602,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",010,320,0,0.0,1.2995205890475223,0.152430989972321,1.3008779110585262,0.15481940619322834,0.19642701499894422,0.28687379782622163,1.3375846780501275,5.870133367869796,0.43296329611107237,0.0,1.2995205890475223,0.152430989972321,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",011,307,0,0.0,1.1958025534179928,0.1562515217448702,1.1943933020068482,0.13903635064414496,0.19741159802653682,0.2809210470132544,1.2361296041231409,6.527944700578263,0.37514572720023953,0.0,1.1958025534179928,0.1562515217448702,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",100,313,0,0.0,-1.1942821421802072,-0.1505140009337925,-1.1926342229430742,-0.14768914590091894,0.20046331464452866,0.29039768912086905,1.2356002306079004,6.153938611190278,0.381008255008502,0.0,-1.1942821421802072,-0.1505140009337925,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",101,315,0,0.0,-1.3039066395993382,-0.14966668234353578,-1.2947624058430738,-0.14878821250584032,0.19674540344728236,0.2856811215452765,1.3412960234264184,5.754117431887854,0.4369845384462321,0.0,-1.3039066395993382,-0.14966668234353578,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",110,308,0,0.0,0.8034350111369687,-0.16882393321876418,0.7931704306195364,-0.17257626364778783,0.2010159778116291,0.28515881708652935,0.8732799380128532,-12.822950408394911,0.38514560683697635,0.0,0.8034350111369687,-0.16882393321876418,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR, проверочная половина",111,306,0,0.0,0.6982496298980488,-0.16058755911520972,0.6932137163619673,-0.1678417490616943,0.1974739737479717,0.2836014114847298,0.7756149804099978,-14.034022976032459,0.4365865890475238,0.0,0.6982496298980488,-0.16058755911520972,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",000,309,7,0.022653721682847898,-0.373361907916119,0.019048237259684454,-0.375062911937328,0.02673635505459082,0.20014860159729064,0.2760653890104564,0.47108963358424616,-6.861425073847069,0.6853621365096135,0.022653721682847898,-0.373361907916119,0.019048237259684454,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",001,312,0,0.0,-0.48696516180532956,0.04017814921604036,-0.4886871423367445,0.0444577677489765,0.19649428198329205,0.2811050663197481,0.5677582561636969,-7.4894433484319585,0.5853964946723793,0.0,-0.48696516180532956,0.04017814921604036,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",010,320,0,0.0,1.618371471161831,0.019705181013746378,1.6220114780926864,0.023944456608310558,0.1949837159985397,0.2846808797308616,1.6431726707365626,0.19804060232903004,0.6807183724206796,0.0,1.618371471161831,0.019705181013746378,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",011,307,0,0.0,1.5103733982441552,0.025307333736111895,1.5094296107097396,0.009835923807948066,0.19564504428657653,0.27866118219431757,1.5358779727416372,0.41368016067370933,0.5821463331648234,0.0,1.5103733982441552,0.025307333736111895,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",100,313,0,0.0,-0.8754204972373312,-0.2832442900591419,-0.8751630284378134,-0.280760851915945,0.19841221034169088,0.2893952818901169,0.9559619752524816,15.962566200579156,0.42968732433843515,0.0,-0.8754204972373312,-0.2832442900591419,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",101,315,0,0.0,-0.9892316514063746,-0.28065422137743334,-0.9818382568622401,-0.2757603775416038,0.19501905783536072,0.28460488159268144,1.0607361943775908,14.358614654856197,0.39732507639385783,0.0,-0.9892316514063746,-0.28065422137743334,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",110,308,0,0.0,1.1180959990534434,-0.2998056444562622,1.1165444649416947,-0.3002686314127111,0.19895336508396577,0.2841330851165556,1.1956808229546807,-15.439137103017439,0.43476753455331757,0.0,1.1180959990534434,-0.2998056444562622,1.0,0.0 +"lab043_prbs11_s_20260819_134831: FIR+DC, проверочная половина",111,306,0,0.0,1.0088879691809087,-0.289894790763652,1.0099893568795157,-0.29316047691907,0.1955407745458894,0.28250782538856484,1.0913190363180871,-16.479503909256373,0.40273457084442965,0.0,1.0088879691809087,-0.289894790763652,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",000,309,0,0.0,-0.9986986521505139,0.0020742102322665558,-0.998698652150514,0.0020742102322665553,1.1102230246251565e-16,4.336808689942018e-19,0.9987008061253084,-0.11899817927704522,0.0024486433617415763,0.0,-0.9986986521505139,0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",001,312,0,0.0,-0.9986986521505139,0.0020742102322665558,-0.998698652150514,0.0020742102322665553,1.1102230246251565e-16,4.336808689942018e-19,0.9987008061253084,-0.11899817927704522,0.002448643361741576,0.0,-0.9986986521505139,0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",010,320,0,0.0,0.9986986521505139,-0.0020742102322665558,0.998698652150514,-0.0020742102322665553,1.1102230246251565e-16,4.336808689942018e-19,0.9987008061253084,-0.11899817927704522,0.002448643361741576,0.0,0.9986986521505139,-0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",011,307,0,0.0,0.998698652150514,-0.0020742102322665558,0.998698652150514,-0.0020742102322665553,0.0,4.336808689942018e-19,0.9987008061253083,-0.11899817927704519,0.002448643361741576,0.0,0.998698652150514,-0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",100,313,0,0.0,-0.998698652150514,0.0020742102322665558,-0.998698652150514,0.0020742102322665553,0.0,4.336808689942018e-19,0.9987008061253083,-0.11899817927704522,0.0024486433617415763,0.0,-0.998698652150514,0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",101,315,0,0.0,-0.9986986521505139,0.0020742102322665558,-0.998698652150514,0.0020742102322665553,1.1102230246251565e-16,4.336808689942018e-19,0.9987008061253084,-0.11899817927704522,0.002448643361741576,0.0,-0.9986986521505139,0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",110,308,0,0.0,0.998698652150514,-0.0020742102322665558,0.998698652150514,-0.0020742102322665553,0.0,4.336808689942018e-19,0.9987008061253083,-0.11899817927704519,0.0024486433617415763,0.0,0.998698652150514,-0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain, проверочная половина",111,306,0,0.0,0.9986986521505139,-0.0020742102322665558,0.998698652150514,-0.0020742102322665553,1.1102230246251565e-16,4.336808689942018e-19,0.9987008061253084,-0.11899817927704519,0.0024486433617415763,0.0,0.9986986521505139,-0.0020742102322665558,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",000,309,0,0.0,-0.7900251952441896,-0.20046517356676105,-0.7900251952441897,-0.20046517356676105,1.1102230246251565e-16,0.0,0.8150620190720285,14.238031306017975,0.29030278063668685,0.0,-0.7900251952441896,-0.20046517356676105,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",001,312,0,0.0,-0.7900251952441897,-0.20046517356676105,-0.7900251952441897,-0.20046517356676105,0.0,0.0,0.8150620190720285,14.238031306017977,0.29030278063668685,0.0,-0.7900251952441897,-0.20046517356676105,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",010,320,0,0.0,1.2047096088092297,-0.2020293594692652,1.2047096088092297,-0.20202935946926517,0.0,2.7755575615628914e-17,1.2215322769558112,-9.519897580765734,0.2876141269590029,0.0,1.2047096088092297,-0.2020293594692652,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",011,307,0,0.0,1.2047096088092297,-0.2020293594692652,1.2047096088092297,-0.20202935946926517,0.0,2.7755575615628914e-17,1.2215322769558112,-9.519897580765736,0.2876141269590029,0.0,1.2047096088092297,-0.2020293594692652,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",100,313,0,0.0,-0.7900251952441897,-0.20046517356676102,-0.7900251952441897,-0.20046517356676105,0.0,2.7755575615628914e-17,0.8150620190720285,14.238031306017977,0.29030278063668685,0.0,-0.7900251952441897,-0.20046517356676102,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",101,315,0,0.0,-0.7900251952441897,-0.20046517356676105,-0.7900251952441897,-0.20046517356676105,0.0,0.0,0.8150620190720285,14.238031306017977,0.29030278063668685,0.0,-0.7900251952441897,-0.20046517356676105,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",110,308,0,0.0,1.2047096088092297,-0.2020293594692652,1.2047096088092297,-0.20202935946926517,0.0,2.7755575615628914e-17,1.2215322769558112,-9.519897580765736,0.2876141269590029,0.0,1.2047096088092297,-0.2020293594692652,1.0,0.0 +"lab043_prbs11_s_20260819_150001: gain+DC, проверочная половина",111,306,0,0.0,1.2047096088092297,-0.2020293594692652,1.2047096088092297,-0.20202935946926517,0.0,2.7755575615628914e-17,1.2215322769558112,-9.519897580765734,0.2876141269590029,0.0,1.2047096088092297,-0.2020293594692652,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",000,309,0,0.0,-0.6710725187039128,0.09344369160270495,-0.6808189048478809,0.08688658919376054,0.24861776919736261,0.24939681573484215,0.7327560296334127,-11.775774154423525,0.4324949547472286,0.0,-0.6710725187039128,0.09344369160270495,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",001,312,0,0.0,-0.8113242081298582,0.11540812260778764,-0.8081534140039225,0.13040019024137117,0.2493341626463008,0.2544357540519286,0.8666832769699392,-11.05082625854985,0.3662322950142799,0.0,-0.8113242081298582,0.11540812260778764,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",010,320,0,0.0,1.3289266306259868,0.10067851591153154,1.3426949214924473,0.10605998994950924,0.2511199624976089,0.2577379117599549,1.3559798575297326,2.9266086491242937,0.43698270754103474,0.0,1.3289266306259868,0.10067851591153154,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",011,307,0,0.0,1.1802435067888202,0.101973530157473,1.1700774347034235,0.09039529156811495,0.24715773383921819,0.25665122898322723,1.2101773670615918,3.2484605533917965,0.3572543410093826,0.0,1.1802435067888202,0.101973530157473,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",100,313,0,0.0,-1.1764242418811381,-0.0968258216781235,-1.1518061648967206,-0.09777527946167801,0.2505627465167484,0.2565413362859851,1.2063782946665622,2.997651605811771,0.3596020850367703,0.0,-1.1764242418811381,-0.0968258216781235,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",101,315,0,0.0,-1.3287331580337447,-0.09387400797742058,-1.334534679715302,-0.08540971044219774,0.24509693947896344,0.25584163913880825,1.3549962424691067,2.7014633170148827,0.43355180307842056,0.0,-1.3287331580337447,-0.09387400797742058,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",110,308,0,0.0,0.8123717207465698,-0.11022990288189691,0.8062426350476075,-0.11104057150832841,0.24784188350971223,0.2540406668700511,0.8667943901674938,-10.621336714112806,0.362503437104132,0.0,0.8123717207465698,-0.11022990288189691,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR, проверочная половина",111,306,0,0.0,0.6674656833129947,-0.103092504726845,0.6609612642366145,-0.11257663061549493,0.24610220513947023,0.25250138487302465,0.7322257028036385,-12.652764288670907,0.4364994596630116,0.0,0.6674656833129947,-0.103092504726845,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",000,309,1,0.003236245954692557,-0.4558810956361874,-0.10644921614059683,-0.464859559487001,-0.1134187823244891,0.2503754783794727,0.2454171919344677,0.5277991871992165,5.199914308613728,0.6236679866091345,0.003236245954692557,-0.4558810956361874,-0.10644921614059683,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",001,312,0,0.0,-0.5985717359206436,-0.08221922560716155,-0.5991076898402583,-0.06700817968895466,0.25135697734444906,0.2501378233111643,0.6527672131207064,2.0372538608124713,0.5048785170357465,0.0,-0.5985717359206436,-0.08221922560716155,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",010,320,0,0.0,1.5416545654680465,-0.09692603936121179,1.5494244024258412,-0.088342869521054,0.2532693831736207,0.25352545001605287,1.5677893528661166,-4.6414289815651015,0.6232959268894408,0.0,1.5416545654680465,-0.09692603936121179,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",011,307,0,0.0,1.3901159265784606,-0.09297851601990005,1.3830271075774219,-0.10137091266408625,0.24928119012571637,0.2522506935226914,1.4184722871289466,-5.045995859433824,0.4983004675029795,0.0,1.3901159265784606,-0.09297851601990005,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",100,313,0,0.0,-0.9636891263962676,-0.29443704710319063,-0.9378749028311261,-0.2926532575514388,0.25132096355275274,0.25381930901456295,1.0294200554294395,14.793780098344119,0.4291587372140098,0.0,-0.9636891263962676,-0.29443704710319063,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",101,315,0,0.0,-1.1187912568514213,-0.2888905959354047,-1.1214625027188978,-0.2742072530579842,0.2460379440928326,0.25312916921017614,1.1752640737502726,12.772959574369287,0.4157688411867369,0.0,-1.1187912568514213,-0.2888905959354047,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",110,308,0,0.0,1.0223042813560053,-0.30523781431249886,1.0208525603382534,-0.3049588756084497,0.2485878198807318,0.25127291859371265,1.107172524312333,-18.19512513033301,0.4299588554499636,0.0,1.0223042813560053,-0.30523781431249886,1.0,0.0 +"lab043_prbs11_s_20260819_150001: FIR+DC, проверочная половина",111,306,0,0.0,0.8747144510135012,-0.2956074210338545,0.8691220060249791,-0.3068578932042683,0.24697951882063934,0.2496747743040441,0.970047916342626,-20.588927935476164,0.4214355680933763,0.0,0.8747144510135012,-0.2956074210338545,1.0,0.0 diff --git a/data/processed/lab043_diagnostics/symbol_frequency_response.csv b/data/processed/lab043_diagnostics/symbol_frequency_response.csv new file mode 100644 index 0000000..cb78c9e --- /dev/null +++ b/data/processed/lab043_diagnostics/symbol_frequency_response.csv @@ -0,0 +1,11 @@ +capture,band_low_hz,band_high_hz,mean_transfer_magnitude,median_transfer_magnitude,mean_transfer_db +lab043_prbs11_s_20260819_134831,10,100,1.002069554851456,1.0049897256864089,0.017957349582434494 +lab043_prbs11_s_20260819_134831,100,500,0.9661028865719044,0.989609261571821,-0.2995324056226316 +lab043_prbs11_s_20260819_134831,500,1000,0.9182988982838727,0.945959496125638,-0.7403187344271658 +lab043_prbs11_s_20260819_134831,1000,5000,0.9790711997415193,1.1012182577421776,-0.18371448813561786 +lab043_prbs11_s_20260819_134831,5000,9000,1.181591435351549,1.1865979003577944,1.449346687495564 +lab043_prbs11_s_20260819_150001,10,100,0.8477370942675726,0.8371031626924971,-1.4347762617504942 +lab043_prbs11_s_20260819_150001,100,500,0.8188526554959003,0.8335873999651358,-1.7358847648402722 +lab043_prbs11_s_20260819_150001,500,1000,0.6403369700347171,0.7224304126735926,-3.871828466762968 +lab043_prbs11_s_20260819_150001,1000,5000,1.0158561997332687,1.0814306852225017,0.13664470851855184 +lab043_prbs11_s_20260819_150001,5000,9000,1.184644227792827,1.1858019728540186,1.4717588532470873 diff --git a/data/processed/lab043_diagnostics/tx_stages_triplets.csv b/data/processed/lab043_diagnostics/tx_stages_triplets.csv new file mode 100644 index 0000000..7eff785 --- /dev/null +++ b/data/processed/lab043_diagnostics/tx_stages_triplets.csv @@ -0,0 +1,33 @@ +source,triplet,count,bit_errors,ber,mean_i,mean_q,median_i,median_q,std_i,std_q,mean_amplitude,mean_phase_error_deg,mean_distance_to_ideal,decision_cross_fraction,raw_mean_i,raw_mean_q,fitted_gain_i,fitted_gain_q +идеальные символы,000,618,0,0.0,-1.0,0.0,-1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,-1.0,0.0,1.0,0.0 +идеальные символы,001,626,0,0.0,-1.0,0.0,-1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,-1.0,0.0,1.0,0.0 +идеальные символы,010,625,0,0.0,1.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,1.0,0.0 +идеальные символы,011,623,0,0.0,1.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,1.0,0.0 +идеальные символы,100,626,0,0.0,-1.0,0.0,-1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,-1.0,0.0,1.0,0.0 +идеальные символы,101,622,0,0.0,-1.0,0.0,-1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,-1.0,0.0,1.0,0.0 +идеальные символы,110,624,0,0.0,1.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,1.0,0.0 +идеальные символы,111,634,0,0.0,1.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,1.0,0.0,1.0,0.0 +после TX RRC,000,618,0,0.0,-0.8447211351853866,-3.16637664624025e-17,-0.8458174838489158,-2.043476895199683e-17,0.08195783733693278,2.7724800523357534e-16,0.8447211351853866,2.1954275938784535e-15,0.1555852928589922,0.0,-0.08445216029186903,-3.5918389295957526e-18,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,001,626,0,0.0,-1.0004050270462215,5.689638487900804e-18,-1.0004890125643966,1.5852663216863385e-17,0.081268756901431,2.8173172396551604e-16,1.0004050270462215,-4.2381688289265534e-16,0.06554468539821465,0.0,-0.10001687205607476,6.406779338691513e-20,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,010,625,0,0.0,1.153590974720758,-3.664785498955269e-18,1.1551605412798769,-3.169233802432743e-18,0.08187034700843802,2.918987738801931e-16,1.153590974720758,-1.1741880644801756e-16,0.15394987495320192,0.0,0.11533184840579361,2.156607645445083e-19,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,011,623,0,0.0,0.9999766504304199,9.543270215936798e-18,1.0004890125643962,1.121968145257086e-17,0.08176939641698396,2.913164939367553e-16,0.9999766504304199,5.344798879995152e-16,0.06626105529395579,0.0,0.0999740445132135,1.4586471186719179e-18,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,100,626,0,0.0,-1.000209162092668,5.858564348969746e-18,-1.0004890125643962,9.042609602079001e-18,0.08157874188457086,2.9409492093087693e-16,1.000209162092668,-2.692785457920202e-16,0.06569607217373102,0.0,-0.09999729018726143,8.105521437455294e-20,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,101,622,0,0.0,-1.154560683753055,2.2676007987771794e-17,-1.155160541279877,2.2885397049242972e-17,0.08131379694938634,2.7413402469108926e-16,1.154560683753055,-1.0789996143200499e-15,0.1548792650197144,0.0,-0.11542879640344733,1.6845231449320436e-18,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,110,624,0,0.0,0.9998120280379368,-6.970963253964133e-18,1.0004890125643966,-1.4807615813401128e-17,0.08131500141429798,2.76893706766613e-16,0.9998120280379368,-3.7885745089968316e-16,0.06579158206105619,0.0,0.09995758616252412,-1.9246920569558615e-19,0.09997637891862943,5.0455730063634775e-19 +после TX RRC,111,634,0,0.0,0.8478758083346417,3.2719991252106793e-18,0.8458174838489161,1.0253160945110266e-17,0.08055200459377172,2.958183391880481e-16,0.8478758083346417,2.793911358621806e-16,0.15252059362674364,0.0,0.08476755309000336,7.549245534916749e-19,0.09997637891862943,5.0455730063634775e-19 +после масштаба 0.70,000,618,0,0.0,-0.8447211351853869,-3.16637664624025e-17,-0.845817483848916,-2.043476895199683e-17,0.08195783733693278,2.7724800523357544e-16,0.8447211351853869,2.1954275938784535e-15,0.15558529285899203,0.0,-0.05911651220430831,-2.514287250717027e-18,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,001,626,0,0.0,-1.0004050270462215,5.6896384879008164e-18,-1.0004890125643968,1.585266321686339e-17,0.08126875690143102,2.817317239655161e-16,1.0004050270462215,-4.238168828926557e-16,0.06554468539821467,0.0,-0.07001181043925234,4.4847455370840576e-20,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,010,625,0,0.0,1.1535909747207584,-3.6647854989552794e-18,1.155160541279877,-3.169233802432749e-18,0.08187034700843805,2.9189877388019315e-16,1.1535909747207584,-1.1741880644801798e-16,0.15394987495320214,0.0,0.0807322938840555,1.5096253518115591e-19,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,011,623,0,0.0,0.9999766504304202,9.543270215936798e-18,1.0004890125643966,1.1219681452570858e-17,0.08176939641698398,2.9131649393675533e-16,0.9999766504304202,5.344798879995148e-16,0.06626105529395582,0.0,0.06998183115924946,1.0210529830703427e-18,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,100,626,0,0.0,-1.000209162092668,5.858564348969753e-18,-1.0004890125643966,9.042609602079007e-18,0.08157874188457087,2.94094920930877e-16,1.000209162092668,-2.6927854579202056e-16,0.06569607217373108,0.0,-0.069998103131083,5.673865006218707e-20,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,101,622,0,0.0,-1.1545606837530553,2.2676007987771806e-17,-1.1551605412798773,2.288539704924298e-17,0.08131379694938637,2.741340246910893e-16,1.1545606837530553,-1.0789996143200503e-15,0.1548792650197147,0.0,-0.08080015748241312,1.17916620145243e-18,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,110,624,0,0.0,0.9998120280379368,-6.97096325396414e-18,1.0004890125643968,-1.4807615813401134e-17,0.081315001414298,2.7689370676661305e-16,0.9998120280379368,-3.788574508996836e-16,0.06579158206105622,0.0,0.06997031031376687,-1.3472844398691038e-19,0.06998346524304058,3.5319011044544367e-19 +после масштаба 0.70,111,634,0,0.0,0.847875808334642,3.2719991252106723e-18,0.8458174838489162,1.0253160945110262e-17,0.08055200459377174,2.9581833918804815e-16,0.847875808334642,2.7939113586218023e-16,0.15252059362674344,0.0,0.059337287163002336,5.284471874441723e-19,0.06998346524304058,3.5319011044544367e-19 +после complex64 и масштаба 2^14,000,618,0,0.0,-0.844721132164728,-3.166376645611102e-17,-0.8458174850382276,-2.043476819321968e-17,0.08195783531366697,2.77248005175605e-16,0.844721132164728,2.1954275974653788e-15,0.15558529653595318,0.0,-968.56494140625,-4.119408197260192e-14,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,001,626,0,0.0,-1.0004050290806876,5.689638700515076e-18,-1.0004890108573807,1.5852663367733532e-17,0.08126874885830437,2.817317238213439e-16,1.0004050290806876,-4.2381688127743276e-16,0.06554467131341328,0.0,-1147.073486328125,7.347805941336938e-16,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,010,625,0,0.0,1.1535909745425856,-3.664784928636354e-18,1.155160536676534,-3.169233843845358e-18,0.0818703482908584,2.918987737957422e-16,1.1535909745425856,-1.1741877813699304e-16,0.15394987537165816,0.0,1322.7178955078125,2.473370087300447e-15,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,011,623,0,0.0,0.9999766517391538,9.54327018742428e-18,1.0004890108573807,1.1219680713135247e-17,0.08176938762246996,2.9131649404141736e-16,0.9999766517391538,5.344798719659834e-16,0.06626104078643395,0.0,1146.5823974609375,1.6728931201458533e-14,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,100,626,0,0.0,-1.0002091641465911,5.858564725237296e-18,-1.0004890108573807,9.042609737912825e-18,0.08157873479071351,2.940949208401175e-16,1.0002091641465911,-2.692785710658976e-16,0.06569605903394975,0.0,-1146.8489990234375,9.296050012053201e-16,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,101,622,0,0.0,-1.1545606831392217,2.2676007803645295e-17,-1.155160536676534,2.2885397145632995e-17,0.08131379747849762,2.7413402489492663e-16,1.1545606831392217,-1.0789995911324584e-15,0.1548792650014609,0.0,-1323.829833984375,1.9319459497891406e-14,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,110,624,0,0.0,0.999812029920823,-6.970963557473678e-18,1.0004890108573807,-1.480761617308785e-17,0.08131499356745366,2.7689370656125954e-16,0.999812029920823,-3.788574570769471e-16,0.06579156845621685,0.0,1146.3936767578125,-2.207392636258414e-15,1146.6090955078125,5.7866669273070975e-15 +после complex64 и масштаба 2^14,111,634,0,0.0,0.8478758048905635,3.2719992899553873e-18,0.8458174850382276,1.025316095104262e-17,0.08055200208688879,2.9581833895628747e-16,0.8478758048905635,2.7939113076495106e-16,0.1525205978252917,0.0,972.1821899414062,8.658077713433708e-15,1146.6090955078125,5.7866669273070975e-15 diff --git a/data/processed/lab043_hackrf_compare/continuity.csv b/data/processed/lab043_hackrf_compare/continuity.csv new file mode 100644 index 0000000..2357599 --- /dev/null +++ b/data/processed/lab043_hackrf_compare/continuity.csv @@ -0,0 +1,4 @@ +boundary_sample,low_phase_jump_rad,high_phase_jump_rad +2490368,-0.2270746402238407,0.936322989443652 +2621440,-1.685691421437402,0.09155935561800127 +2752512,-1.962337630395535,1.0596287040560621 diff --git a/data/processed/lab043_hackrf_compare/report.txt b/data/processed/lab043_hackrf_compare/report.txt new file mode 100644 index 0000000..82fca93 --- /dev/null +++ b/data/processed/lab043_hackrf_compare/report.txt @@ -0,0 +1,32 @@ +Lab043: единственный сравнительный S с HackRF + +Результат: недействителен для BER и локализации асимметрии 000/001. +Классификация: D, данных недостаточно для локализации. + +Raw: data\raw\lab043_hackrf\lab043_hackrf_prbs11_s_20260820_141720.npy +SHA-256: 02cd707262c042404cd8b9ac5176e2e24ee74fcf84e5f64223c2c22015958962 +Waveform SHA-256: 981aeddde3ed63192c24bfbe8a11f58b2896b50a643dd5faf3f81eaa9c2bf0bc + +HackRF: 000000000000000097c063dc323b97d3, firmware 2024.02.1 (API:1.08), libhackrf 2024.02.1 (0.9). +RX: 435 МГц, запрос 2 400 000 отсчётов/с, RF amp OFF, LNA 0 дБ, VGA 0 дБ. +Pluto+: фактически 435000000 Гц, 2399998 отсчётов/с, gain -30.0 дБ, RF bandwidth 200000 Гц. + +Программная сборка: 32 рабочих callback после одного заранее отброшенного разогревочного; индексы последовательны, дубликатов полных блоков 0, пропусков очереди 0. +Средняя callback-скорость: 2399817.412 отсчётов/с. +USB-потери аппаратно не доказаны и не опровергнуты: внутреннего счётчика API нет. + +Два тона найдены неизменным спектральным порогом 10 дБ: нижний +15.582 дБ, верхний +15.510 дБ. +Максимальный рост полной мощности: только 0.871 дБ, поэтому исходный широкополосный локатор не сработал. +Предварительная оценка CFO: +462.074 Гц (+1.062239 ppm). +Предварительная оценка SRO: +273.149 ppm. +Эти оценки нельзя считать итоговыми: RMSE фазовой модели 19.909 рад и в калибровке подтверждено 3 фазовых разрыва из 3 проверенных границ; максимум 1.962 рад при пределе 0,25 рад. + +Маркер: N/A. +BER (доля ошибочных битов): N/A. +BER по четвертям: N/A. +EVM (величина вектора ошибки): N/A. +Тройки 000–111, серии и кластеры: N/A, чтобы не выдавать недостоверный захват за измерение. + +Сравнение с RTL-SDR невозможно: два RTL-SDR S валидны, а HackRF S не прошёл контроль непрерывности. Главный текущий подозреваемый не 000/001-механизм, а достоверность приёма HackRF на минимальном усилении и/или непрерывность USB/драйвера. Усиления и пороги не менялись, повторный TX не выполнялся. + +Следующий шаг, пока не выполненный: отдельно доказать непрерывность HackRF RX и достаточную фазовую точность на 2,4 Мвыб/с без изменения протокола; только затем отдельно решить, разрешать ли новый S или изменение усиления. diff --git a/data/processed/lab043_hackrf_compare/run_lengths.csv b/data/processed/lab043_hackrf_compare/run_lengths.csv new file mode 100644 index 0000000..19554cc --- /dev/null +++ b/data/processed/lab043_hackrf_compare/run_lengths.csv @@ -0,0 +1,15 @@ +bit,run_length,status,reason +0,1,N/A,захват недействителен из-за фазовых разрывов +0,2,N/A,захват недействителен из-за фазовых разрывов +0,3,N/A,захват недействителен из-за фазовых разрывов +0,4,N/A,захват недействителен из-за фазовых разрывов +0,5,N/A,захват недействителен из-за фазовых разрывов +0,6,N/A,захват недействителен из-за фазовых разрывов +0,7+,N/A,захват недействителен из-за фазовых разрывов +1,1,N/A,захват недействителен из-за фазовых разрывов +1,2,N/A,захват недействителен из-за фазовых разрывов +1,3,N/A,захват недействителен из-за фазовых разрывов +1,4,N/A,захват недействителен из-за фазовых разрывов +1,5,N/A,захват недействителен из-за фазовых разрывов +1,6,N/A,захват недействителен из-за фазовых разрывов +1,7+,N/A,захват недействителен из-за фазовых разрывов diff --git a/data/processed/lab043_hackrf_compare/summary.csv b/data/processed/lab043_hackrf_compare/summary.csv new file mode 100644 index 0000000..05bfaf7 --- /dev/null +++ b/data/processed/lab043_hackrf_compare/summary.csv @@ -0,0 +1,2 @@ +receiver,capture,result_valid,invalid_reason,raw_iq_sha256,sample_count,marker_detected,marker_correlation,carrier_offset_hz_preliminary,carrier_offset_ppm_preliminary,sample_clock_error_ppm_preliminary,phase_fit_rmse_rad,mode_b_bit_errors,mode_b_ber,mode_b_evm_percent,peak_magnitude,clipped_component_fraction,callback_count,accepted_callback_count,program_continuity_confirmed,usb_continuity_proven,mean_callback_stream_sample_rate_hz,confirmed_phase_discontinuities,maximum_phase_jump_rad,classification +HackRF,lab043_hackrf_prbs11_s_20260820_141720,False,в той же записи подтверждён фазовый разрыв,02cd707262c042404cd8b9ac5176e2e24ee74fcf84e5f64223c2c22015958962,4139314,False,nan,462.0737655540943,1.062238541503665,273.14863907035306,19.90879367182428,N/A,N/A,N/A,0.0390625,0.0,33,32,True,False,2399817.411963232,3,1.962337630395535,D diff --git a/data/processed/lab043_hackrf_compare/triplets.csv b/data/processed/lab043_hackrf_compare/triplets.csv new file mode 100644 index 0000000..2941c46 --- /dev/null +++ b/data/processed/lab043_hackrf_compare/triplets.csv @@ -0,0 +1,9 @@ +triplet,status,reason +000,N/A,захват недействителен из-за фазовых разрывов +001,N/A,захват недействителен из-за фазовых разрывов +010,N/A,захват недействителен из-за фазовых разрывов +011,N/A,захват недействителен из-за фазовых разрывов +100,N/A,захват недействителен из-за фазовых разрывов +101,N/A,захват недействителен из-за фазовых разрывов +110,N/A,захват недействителен из-за фазовых разрывов +111,N/A,захват недействителен из-за фазовых разрывов diff --git a/data/processed/lab043_hackrf_continuity/discontinuities.csv b/data/processed/lab043_hackrf_continuity/discontinuities.csv new file mode 100644 index 0000000..1552283 --- /dev/null +++ b/data/processed/lab043_hackrf_continuity/discontinuities.csv @@ -0,0 +1 @@ +received_symbol_index,expected_state_number,recognized_state_number,expected_next_position,actual_next_position,position_delta_symbols,minimum_confirmed_gap_symbols,type,estimated_raw_sample_index,nearest_callback_index,signed_distance_to_callback_samples,distance_category,distance_to_nearest_midpoint_samples,local_mean_amplitude,local_peak_amplitude,before_mean_amplitude,after_mean_amplitude,local_exact_zero_fraction,exact_lost_or_repeated_iq_samples diff --git a/data/processed/lab043_hackrf_continuity/report.txt b/data/processed/lab043_hackrf_continuity/report.txt new file mode 100644 index 0000000..25b64c8 --- /dev/null +++ b/data/processed/lab043_hackrf_continuity/report.txt @@ -0,0 +1,13 @@ +Lab043: цифровой счётчик непрерывности HackRF + +Классификация: D. +Причина: Недостаточно достоверно восстановленных полезных символов. +Передано символов: 110000, полезных: 100000. +Восстановлено полезных позиций: 0. +Обычных ошибок символов: N/A. +Подтверждённых скачков позиции: 0. +Пропусков: 0; повторов: 0; вставок: 0. +Непрерывность цифровой последовательности: НЕ ОПРЕДЕЛЕНО. +Непрерывность сырых I/Q-отсчётов: НЕ ОПРЕДЕЛЕНО. + +I/Q означает синфазные и квадратурные комплексные отсчёты. diff --git a/data/processed/lab043_hackrf_continuity/summary.csv b/data/processed/lab043_hackrf_continuity/summary.csv new file mode 100644 index 0000000..082c361 --- /dev/null +++ b/data/processed/lab043_hackrf_continuity/summary.csv @@ -0,0 +1,2 @@ +classification,reason,digital_sequence_continuity,raw_iq_continuity,raw_iq_reason,transmitted_symbol_count,useful_transmitted_symbol_count,recovered_transmitted_position_count,recovered_useful_position_count,ordinary_symbol_error_count,confirmed_position_jump_count,skip_event_count,repeat_event_count,insertion_event_count,ambiguous_event_count,anchor_count,stable_run_count,recovered_symbol_count,carrier_offset_hz,carrier_peak_above_noise_db,samples_per_symbol,sample_clock_error_ppm +D,Недостаточно достоверно восстановленных полезных символов,НЕ ОПРЕДЕЛЕНО,НЕ ОПРЕДЕЛЕНО,"Цифровая последовательность доказывает порядок символов, но сама по себе не доказывает сохранность каждого I/Q-отсчёта",110000,100000,0,0,N/A,0,0,0,0,0,0,0,0,-70.86010002260534,5.072038638260837,120.00238998532383,19.916544365283784 diff --git a/data/processed/lab043_hackrf_counter/discontinuities.csv b/data/processed/lab043_hackrf_counter/discontinuities.csv new file mode 100644 index 0000000..acf26df --- /dev/null +++ b/data/processed/lab043_hackrf_counter/discontinuities.csv @@ -0,0 +1 @@ +received_symbol_index,expected_state_number,recognized_state_number,expected_next_position,actual_next_position,position_delta_symbols,minimum_confirmed_gap_symbols,type,estimated_raw_sample_index,nearest_callback_index,signed_distance_to_callback_samples,distance_category,distance_to_nearest_midpoint_samples,local_mean_amplitude,local_peak_amplitude,before_mean_amplitude,after_mean_amplitude,local_exact_zero_fraction,exact_lost_or_repeated_iq_samples,exact_iq_sample_count_status,exact_iq_sample_count_reason diff --git a/data/processed/lab043_hackrf_counter/report.txt b/data/processed/lab043_hackrf_counter/report.txt new file mode 100644 index 0000000..ef0cb58 --- /dev/null +++ b/data/processed/lab043_hackrf_counter/report.txt @@ -0,0 +1,13 @@ +Lab043: цифровой счётчик непрерывности HackRF + +Классификация: D. +Причина: Недостаточно достоверно восстановленных полезных символов. +Передано символов: 110000, полезных: 100000. +Восстановлено полезных позиций: 0. +Обычных ошибок символов: None. +Подтверждённых скачков позиции: 0. +Пропусков: 0; повторов: 0; вставок: 0. +Непрерывность цифровой последовательности: НЕ ОПРЕДЕЛЕНО. +Непрерывность сырых I/Q-отсчётов: НЕ ОПРЕДЕЛЕНО. + +I/Q означает синфазные и квадратурные комплексные отсчёты. diff --git a/data/processed/lab043_hackrf_counter/summary.csv b/data/processed/lab043_hackrf_counter/summary.csv new file mode 100644 index 0000000..a951c5c --- /dev/null +++ b/data/processed/lab043_hackrf_counter/summary.csv @@ -0,0 +1,2 @@ +classification,reason,digital_sequence_continuity,raw_iq_continuity,raw_iq_reason,transmitted_symbol_count,useful_transmitted_symbol_count,recovered_transmitted_position_count,recovered_useful_position_count,ordinary_symbol_error_count,ordinary_symbol_error_count_status,confirmed_position_jump_count,skip_event_count,repeat_event_count,insertion_event_count,ambiguous_event_count,anchor_count,stable_run_count,recovered_symbol_count,carrier_offset_hz,carrier_peak_above_noise_db,samples_per_symbol,sample_clock_error_ppm +D,Недостаточно достоверно восстановленных полезных символов,НЕ ОПРЕДЕЛЕНО,НЕ ОПРЕДЕЛЕНО,"Цифровая последовательность доказывает порядок символов, но сама по себе не доказывает сохранность каждого I/Q-отсчёта",110000,100000,0,0,,not_computed,0,0,0,0,0,0,0,0,-75.30663390057586,4.523106594908998,120.0023194133997,19.328444997634975 diff --git a/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_155140.json b/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_155140.json new file mode 100644 index 0000000..0fa56a0 --- /dev/null +++ b/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_155140.json @@ -0,0 +1,205 @@ +{ + "experiment": "Lab043 HackRF RX gain ladder with one continuous tone", + "started_utc": "2026-08-20T15:51:40.472441Z", + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF RX; antennas removed", + "method": "one continuous cyclic Pluto tone; separate short HackRF RX sessions per gain point", + "fixed_gain_ladder": [ + [ + 0, + 0 + ], + [ + 8, + 8 + ], + [ + 16, + 8 + ], + [ + 16, + 16 + ], + [ + 24, + 16 + ], + [ + 24, + 24 + ] + ], + "fixed_thresholds": { + "desirable_tone_over_noise_db": 35.0, + "near_limit_component": 0.9, + "preferred_max_component": 0.75, + "clipped_component": 0.9921875, + "near_limit_fraction_stop": 0.0001, + "nonlinear_harmonic_limit_dbc": -30.0 + }, + "pluto_preflight": { + "uri": "ip:192.168.2.1", + "context_opened": true, + "phy_name": "ad9361-phy", + "tx_device_name": "cf-ad9361-dds-core-lpc", + "first_tx_channel": "voltage0" + }, + "hackrf_preflight": { + "model": "HackRF One", + "serial_number": "000000000000000097c063dc323b97d3", + "firmware_version": "2024.02.1 (API:1.08)", + "libhackrf_version": "2024.02.1 (0.9)", + "hardware_revision": "r9", + "hackrf_info_version": "2024.02.1", + "information_query": "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", + "requested_center_frequency_hz": 435000000, + "set_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000.0, + "set_sample_rate_hz": 2400000.0, + "sample_rate_readback_available": false, + "center_frequency_readback_available": false, + "rf_amp_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "antenna_bias_enabled": false, + "device_opened_via_libhackrf": true, + "rx_started": true, + "probe_sample_count": 65536, + "probe_dtype": "complex64", + "probe_mean_i": -0.02207362651824951, + "probe_mean_q": 0.023364543914794922, + "probe_rms": 0.032287873327732086, + "probe_peak_magnitude": 0.03314562886953354, + "probe_clipped_component_fraction": 0.0, + "probe_stream": { + "callback_count_total": 2, + "warmup_callback_count": 1, + "accepted_callback_count": 1, + "received_bytes_including_warmup": 524288, + "accepted_bytes": 131072, + "accepted_sample_count": 65536, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 14833.3008998, + "stream_stopped_monotonic": 14833.4274047, + "stream_wall_seconds": 0.12650489999941783, + "accepted_interval_seconds": 0.05461333333333333, + "mean_callback_stream_sample_rate_hz": 2400000.0, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + } + }, + "tx_parameters": { + "requested_center_frequency_hz": 435000000, + "actual_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000, + "actual_sample_rate_hz": 2399998, + "requested_gain_db": -30.0, + "actual_gain_db": -30.0, + "requested_rf_bandwidth_hz": 200000, + "actual_rf_bandwidth_hz": 200000 + }, + "rx_parameters": { + "requested_center_frequency_hz": 435000000, + "set_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000.0, + "set_sample_rate_hz": 2400000.0, + "sample_rate_readback_available": false, + "center_frequency_readback_available": false, + "rf_amp_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "antenna_bias_enabled": false + }, + "actual_transmitted_tone_offset_hz": 49999.958333333336, + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "completed_step_count": 1, + "stop_reason": "перегрузка/близость к пределу/гармоническая нелинейность", + "rows": [ + { + "step_index": 0, + "lna_gain_db": 0, + "vga_gain_db": 0, + "gain_configuration": { + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "gain_readback_available": false, + "set_calls_succeeded": true + }, + "capture_diagnostics": { + "callback_count_total": 20, + "warmup_callback_count": 1, + "accepted_callback_count": 19, + "received_bytes_including_warmup": 5242880, + "accepted_bytes": 4800000, + "accepted_sample_count": 2400000, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 14833.9455921, + "stream_stopped_monotonic": 14835.0408997, + "stream_wall_seconds": 1.095307599998705, + "accepted_interval_seconds": 1.015633833332328, + "mean_callback_stream_sample_rate_hz": 2452033.319753656, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + }, + "metrics": { + "sample_count": 1800000, + "fft_samples": 262144, + "fft_segment_count": 6, + "fft_resolution_hz": 9.1552734375, + "tone_frequency_hz": 50553.32330951437, + "tone_peak_bin_hz": 50555.419921875, + "tone_power_dbfs": -81.28323863999425, + "noise_floor_dbfs_per_fft_bin": -102.8470903549844, + "tone_over_noise_db": 21.56385171499015, + "mean_amplitude": 0.03218085318803787, + "rms_amplitude": 0.03224262222647667, + "maximum_amplitude": 0.03314562886953354, + "maximum_component": 0.0234375, + "near_limit_component_fraction": 0.0, + "clipped_component_fraction": 0.0, + "dc_mean_i": -0.021994179114699364, + "dc_mean_q": 0.023369232192635536, + "second_harmonic_dbc": -17.033931670944185, + "third_harmonic_dbc": -16.93934642712324, + "harmonic_nonlinearity_detected": true, + "stop_gain_escalation": true, + "stop_reason": "перегрузка/близость к пределу/гармоническая нелинейность" + } + } + ], + "preferred_gain": null, + "digital_experiment_authorized": false +} \ No newline at end of file diff --git a/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_161424.json b/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_161424.json new file mode 100644 index 0000000..d313ead --- /dev/null +++ b/data/processed/lab043_hackrf_gain_ladder/lab043_hackrf_gain_ladder_20260820_161424.json @@ -0,0 +1,416 @@ +{ + "experiment": "Lab043 HackRF RX gain ladder with one continuous tone", + "started_utc": "2026-08-20T16:14:24.031539Z", + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF RX; antennas removed", + "method": "one continuous cyclic Pluto tone; separate short HackRF RX sessions per gain point", + "fixed_gain_ladder": [ + [ + 0, + 0 + ], + [ + 8, + 8 + ], + [ + 16, + 8 + ], + [ + 16, + 16 + ], + [ + 24, + 16 + ], + [ + 24, + 24 + ] + ], + "fixed_thresholds": { + "desirable_tone_over_noise_db": 35.0, + "near_limit_component": 0.9, + "preferred_max_component": 0.75, + "clipped_component": 0.9921875, + "near_limit_fraction_stop": 0.0001, + "nonlinear_harmonic_limit_dbc": -30.0, + "harmonic_detection_margin_db": 10.0, + "harmonic_search_half_width_hz": 2000.0, + "harmonic_noise_outer_half_width_hz": 10000.0 + }, + "pluto_preflight": { + "uri": "ip:192.168.2.1", + "context_opened": true, + "phy_name": "ad9361-phy", + "tx_device_name": "cf-ad9361-dds-core-lpc", + "first_tx_channel": "voltage0" + }, + "hackrf_preflight": { + "model": "HackRF One", + "serial_number": "000000000000000097c063dc323b97d3", + "firmware_version": "2024.02.1 (API:1.08)", + "libhackrf_version": "2024.02.1 (0.9)", + "hardware_revision": "r9", + "hackrf_info_version": "2024.02.1", + "information_query": "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", + "requested_center_frequency_hz": 435000000, + "set_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000.0, + "set_sample_rate_hz": 2400000.0, + "sample_rate_readback_available": false, + "center_frequency_readback_available": false, + "rf_amp_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "antenna_bias_enabled": false, + "device_opened_via_libhackrf": true, + "rx_started": true, + "probe_sample_count": 65536, + "probe_dtype": "complex64", + "probe_mean_i": -0.02216637134552002, + "probe_mean_q": 0.023369193077087402, + "probe_rms": 0.032346732914447784, + "probe_peak_magnitude": 0.03314562886953354, + "probe_clipped_component_fraction": 0.0, + "probe_stream": { + "callback_count_total": 2, + "warmup_callback_count": 1, + "accepted_callback_count": 1, + "received_bytes_including_warmup": 524288, + "accepted_bytes": 131072, + "accepted_sample_count": 65536, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 16196.8526212, + "stream_stopped_monotonic": 16196.9628548, + "stream_wall_seconds": 0.11023360000035609, + "accepted_interval_seconds": 0.05461333333333333, + "mean_callback_stream_sample_rate_hz": 2400000.0, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + } + }, + "tx_parameters": { + "requested_center_frequency_hz": 435000000, + "actual_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000, + "actual_sample_rate_hz": 2399998, + "requested_gain_db": -30.0, + "actual_gain_db": -30.0, + "requested_rf_bandwidth_hz": 200000, + "actual_rf_bandwidth_hz": 200000 + }, + "rx_parameters": { + "requested_center_frequency_hz": 435000000, + "set_center_frequency_hz": 435000000, + "requested_sample_rate_hz": 2400000.0, + "set_sample_rate_hz": 2400000.0, + "sample_rate_readback_available": false, + "center_frequency_readback_available": false, + "rf_amp_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "antenna_bias_enabled": false + }, + "actual_transmitted_tone_offset_hz": 49999.958333333336, + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "completed_step_count": 3, + "stop_reason": "перегрузка/близость к пределу/гармоническая нелинейность", + "rows": [ + { + "step_index": 0, + "lna_gain_db": 0, + "vga_gain_db": 0, + "gain_configuration": { + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "lna_gain_db": 0, + "vga_gain_db": 0, + "gain_readback_available": false, + "set_calls_succeeded": true + }, + "capture_diagnostics": { + "callback_count_total": 20, + "warmup_callback_count": 1, + "accepted_callback_count": 19, + "received_bytes_including_warmup": 5242880, + "accepted_bytes": 4800000, + "accepted_sample_count": 2400000, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 16197.5058473, + "stream_stopped_monotonic": 16198.599203, + "stream_wall_seconds": 1.0933556999989378, + "accepted_interval_seconds": 1.0374631333339555, + "mean_callback_stream_sample_rate_hz": 2400439.996356343, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + }, + "metrics": { + "sample_count": 1800000, + "fft_samples": 262144, + "fft_segment_count": 6, + "fft_resolution_hz": 9.1552734375, + "tone_frequency_hz": 50555.09081574355, + "tone_peak_bin_hz": 50555.419921875, + "tone_power_dbfs": -79.8521010939479, + "noise_floor_dbfs_per_fft_bin": -102.22903287394465, + "tone_over_noise_db": 22.376931779996752, + "mean_amplitude": 0.03206367790699005, + "rms_amplitude": 0.032130662351846695, + "maximum_amplitude": 0.03493856266140938, + "maximum_component": 0.03125, + "near_limit_component_fraction": 0.0, + "clipped_component_fraction": 0.0, + "dc_mean_i": -0.02178940176963806, + "dc_mean_q": 0.023389514535665512, + "second_harmonic": { + "order": 2, + "expected_frequency_hz": 101110.1816314871, + "peak_frequency_hz": 103042.6025390625, + "peak_power_dbfs": -97.16303551095399, + "local_noise_floor_dbfs_per_fft_bin": -102.3624373861, + "above_local_noise_db": 5.199401875146009, + "relative_to_fundamental_db": -17.310934417006095, + "credible": false, + "nonlinear": false + }, + "third_harmonic": { + "order": 3, + "expected_frequency_hz": 151665.27244723076, + "peak_frequency_hz": 151263.427734375, + "peak_power_dbfs": -97.44245378672434, + "local_noise_floor_dbfs_per_fft_bin": -102.34809348021747, + "above_local_noise_db": 4.905639693493129, + "relative_to_fundamental_db": -17.59035269277645, + "credible": false, + "nonlinear": false + }, + "second_harmonic_dbc": -17.310934417006095, + "third_harmonic_dbc": -17.59035269277645, + "harmonic_nonlinearity_detected": false, + "stop_gain_escalation": false, + "stop_reason": "" + } + }, + { + "step_index": 1, + "lna_gain_db": 8, + "vga_gain_db": 8, + "gain_configuration": { + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "lna_gain_db": 8, + "vga_gain_db": 8, + "gain_readback_available": false, + "set_calls_succeeded": true + }, + "capture_diagnostics": { + "callback_count_total": 15, + "warmup_callback_count": 1, + "accepted_callback_count": 14, + "received_bytes_including_warmup": 3932160, + "accepted_bytes": 3600000, + "accepted_sample_count": 1800000, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 16198.9768347, + "stream_stopped_monotonic": 16199.7983054, + "stream_wall_seconds": 0.8214706999988266, + "accepted_interval_seconds": 0.7651954333319796, + "mean_callback_stream_sample_rate_hz": 2398090.6315784073, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + }, + "metrics": { + "sample_count": 1800000, + "fft_samples": 262144, + "fft_segment_count": 6, + "fft_resolution_hz": 9.1552734375, + "tone_frequency_hz": 50555.29344158161, + "tone_peak_bin_hz": 50555.419921875, + "tone_power_dbfs": -75.07841275653543, + "noise_floor_dbfs_per_fft_bin": -105.14911967255102, + "tone_over_noise_db": 30.070706916015595, + "mean_amplitude": 0.03427855297923088, + "rms_amplitude": 0.03433648869395256, + "maximum_amplitude": 0.04207159951329231, + "maximum_component": 0.0390625, + "near_limit_component_fraction": 0.0, + "clipped_component_fraction": 0.0, + "dc_mean_i": -0.015552565455436707, + "dc_mean_q": 0.030518028885126114, + "second_harmonic": { + "order": 2, + "expected_frequency_hz": 101110.58688316331, + "peak_frequency_hz": 101110.83984375, + "peak_power_dbfs": -96.46551235195447, + "local_noise_floor_dbfs_per_fft_bin": -105.04563379125153, + "above_local_noise_db": 8.58012143929706, + "relative_to_fundamental_db": -21.387099595419045, + "credible": false, + "nonlinear": false + }, + "third_harmonic": { + "order": 3, + "expected_frequency_hz": 151665.88032474485, + "peak_frequency_hz": 150769.04296875, + "peak_power_dbfs": -100.56316044640394, + "local_noise_floor_dbfs_per_fft_bin": -105.10576449464062, + "above_local_noise_db": 4.542604048236683, + "relative_to_fundamental_db": -25.484747689868513, + "credible": false, + "nonlinear": false + }, + "second_harmonic_dbc": -21.387099595419045, + "third_harmonic_dbc": -25.484747689868513, + "harmonic_nonlinearity_detected": false, + "stop_gain_escalation": false, + "stop_reason": "" + } + }, + { + "step_index": 2, + "lna_gain_db": 16, + "vga_gain_db": 8, + "gain_configuration": { + "rf_amp_enabled": false, + "antenna_bias_enabled": false, + "lna_gain_db": 16, + "vga_gain_db": 8, + "gain_readback_available": false, + "set_calls_succeeded": true + }, + "capture_diagnostics": { + "callback_count_total": 15, + "warmup_callback_count": 1, + "accepted_callback_count": 14, + "received_bytes_including_warmup": 3932160, + "accepted_bytes": 3600000, + "accepted_sample_count": 1800000, + "callback_indexes_sequential": true, + "program_queue_missing_callback_count": 0, + "duplicate_full_block_hash_count": 0, + "observed_buffer_lengths": [ + 262144 + ], + "observed_valid_lengths": [ + 262144 + ], + "stream_started_monotonic": 16200.181376, + "stream_stopped_monotonic": 16201.0025195, + "stream_wall_seconds": 0.8211434999993799, + "accepted_interval_seconds": 0.7650824333337428, + "mean_callback_stream_sample_rate_hz": 2398444.8211733233, + "expected_callback_bytes": 262144, + "observed_callback_byte_sizes": [ + 262144 + ], + "observed_callback_buffer_lengths": [ + 262144 + ], + "usb_loss_counter_available": false, + "usb_loss_claim": "N/A: libhackrf callback API не предоставляет счётчик потерь" + }, + "metrics": { + "sample_count": 1800000, + "fft_samples": 262144, + "fft_segment_count": 6, + "fft_resolution_hz": 9.1552734375, + "tone_frequency_hz": 50555.418595322284, + "tone_peak_bin_hz": 50555.419921875, + "tone_power_dbfs": -65.25476329876575, + "noise_floor_dbfs_per_fft_bin": -103.39135933360873, + "tone_over_noise_db": 38.13659603484298, + "mean_amplitude": 0.033912722021341324, + "rms_amplitude": 0.0339985117316246, + "maximum_amplitude": 0.04207159951329231, + "maximum_component": 0.0390625, + "near_limit_component_fraction": 0.0, + "clipped_component_fraction": 0.0, + "dc_mean_i": -0.015441679395735264, + "dc_mean_q": 0.030141202732920647, + "second_harmonic": { + "order": 2, + "expected_frequency_hz": 101110.83719064458, + "peak_frequency_hz": 101110.83984375, + "peak_power_dbfs": -83.20641938155373, + "local_noise_floor_dbfs_per_fft_bin": -103.4224986210237, + "above_local_noise_db": 20.216079239469977, + "relative_to_fundamental_db": -17.951656082787977, + "credible": true, + "nonlinear": true + }, + "third_harmonic": { + "order": 3, + "expected_frequency_hz": 151666.25578596676, + "peak_frequency_hz": 153103.6376953125, + "peak_power_dbfs": -98.4311096772575, + "local_noise_floor_dbfs_per_fft_bin": -103.40473622499643, + "above_local_noise_db": 4.973626547738931, + "relative_to_fundamental_db": -33.17634637849174, + "credible": false, + "nonlinear": false + }, + "second_harmonic_dbc": -17.951656082787977, + "third_harmonic_dbc": -33.17634637849174, + "harmonic_nonlinearity_detected": true, + "stop_gain_escalation": true, + "stop_reason": "перегрузка/близость к пределу/гармоническая нелинейность" + } + } + ], + "preferred_gain": { + "step_index": 1, + "lna_gain_db": 8, + "vga_gain_db": 8, + "tone_over_noise_db": 30.070706916015595, + "desirable_margin_reached": false + }, + "digital_experiment_authorized": false +} \ No newline at end of file diff --git a/experiments/lab043_bpsk_asymmetry_diagnostics.py b/experiments/lab043_bpsk_asymmetry_diagnostics.py new file mode 100644 index 0000000..79a9b75 --- /dev/null +++ b/experiments/lab043_bpsk_asymmetry_diagnostics.py @@ -0,0 +1,1034 @@ +"""Офлайн-диагностика асимметрии ошибок BPSK Lab043. + +Скрипт читает только сохранённые NPY/JSON, проверяет SHA-256 и не импортирует +аппаратные библиотеки. Результаты предназначены для диагностики, а не для +автоматического изменения алгоритма приёмника. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from matplotlib import pyplot as plt +from scipy.signal import csd, fftconvolve, welch + +from experiments import lab043_pluto_to_rtlsdr as lab043 +from protocol import bpsk_radio as radio + + +VALID_CAPTURE_NAMES = ( + "lab043_prbs11_s_20260819_134831.json", + "lab043_prbs11_s_20260819_150001.json", +) +TRIPLETS = tuple(f"{value:03b}" for value in range(8)) + + +@dataclass(frozen=True) +class CaptureAnalysis: + name: str + metadata: dict + raw_bpsk: np.ndarray + coarse_corrected: np.ndarray + matched: np.ndarray + payload_bits: np.ndarray + payload_symbols: np.ndarray + payload_normalized: np.ndarray + sample_phase: int + start_symbol: int + marker_score: float + residual_cfo_hz: float + sha256: str + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _known_sequence_phase_fit( + received: np.ndarray, + expected: np.ndarray, + symbol_rate: float, + block_symbols: int = 64, +) -> tuple[np.ndarray, float, float]: + """Убрать только общую линейную фазу по известной PRBS для диагностики.""" + + usable = len(received) // block_symbols * block_symbols + despread = (received[:usable] * np.conj(expected[:usable])).reshape( + -1, block_symbols + ) + block_sums = np.sum(despread, axis=1) + centers = ( + np.arange(len(block_sums), dtype=np.float64) * block_symbols + + (block_symbols - 1) / 2.0 + ) + phases = np.unwrap(np.angle(block_sums)) + weights = np.maximum(np.abs(block_sums), 1e-12) + design = np.column_stack((np.ones(len(centers)), centers)) + coefficients = np.zeros(2, dtype=np.float64) + for _ in range(12): + root = np.sqrt(weights) + coefficients, *_ = np.linalg.lstsq( + design * root[:, None], phases * root, rcond=None + ) + residual = phases - design @ coefficients + center = float(np.median(residual)) + scale = 1.4826 * float(np.median(np.abs(residual - center))) + 1e-12 + limit = 1.345 * scale + robust = np.ones_like(residual) + outlier = np.abs(residual) > limit + robust[outlier] = limit / np.abs(residual[outlier]) + weights = np.maximum(np.abs(block_sums), 1e-12) * robust + indexes = np.arange(len(received), dtype=np.float64) + corrected = received * np.exp(-1j * (coefficients[0] + coefficients[1] * indexes)) + residual_cfo_hz = float(coefficients[1] * symbol_rate / (2.0 * np.pi)) + fit_rmse = float(np.sqrt(np.mean((phases - design @ coefficients) ** 2))) + return corrected, residual_cfo_hz, fit_rmse + + +def _normalize_symbols(received: np.ndarray, expected: np.ndarray) -> tuple[np.ndarray, complex]: + gain = np.vdot(expected, received) / (np.vdot(expected, expected) + 1e-15) + return received / gain, complex(gain) + + +def load_capture( + raw_dir: Path, + metadata_name: str, + *, + require_processing_success: bool = True, +) -> CaptureAnalysis: + metadata_path = raw_dir / metadata_name + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + iq_path = raw_dir / metadata["iq_file"] + actual_sha = sha256_file(iq_path) + if actual_sha != metadata["iq_sha256"]: + raise RuntimeError(f"SHA-256 не совпал: {iq_path}") + if metadata.get("capture_status") != "captured": + raise RuntimeError(f"Захват не имеет статуса captured: {metadata_path}") + analysis = metadata.get("analysis", {}) + if metadata.get("processing_status") != "success" or ( + require_processing_success and not analysis.get("processing_success", False) + ): + raise RuntimeError(f"Обработка захвата неуспешна: {metadata_path}") + continuity = analysis.get("continuity", {}) + if continuity.get("confirmed_discontinuity_count") != 0: + raise RuntimeError(f"В захвате есть подтверждённые разрывы: {metadata_path}") + + raw = np.load(iq_path, mmap_mode="r") + sections = analysis["sections"] + raw_bpsk = np.asarray( + raw[sections["bpsk_slice_start_sample"] : sections["bpsk_slice_end_sample"]], + dtype=np.complex128, + ) + fs = float(metadata["actual_sample_rate_hz"]) + coarse = radio.apply_coarse_frequency_correction( + raw_bpsk, analysis["calibration"]["carrier_offset_hz"], fs + ) + taps = radio.root_raised_cosine_taps( + lab043.LAB042_RRC_ROLLOFF, + lab043.SAMPLES_PER_SYMBOL, + lab043.LAB042_RRC_SPAN_SYMBOLS, + ) + matched = fftconvolve(coarse, taps, mode="full") + marker_bits, marker_symbols = radio.build_frame_marker() + found = radio.find_radio_frame(matched, marker_symbols, lab043.SAMPLES_PER_SYMBOL) + pilot_count = int(metadata["waveform"].get("pilot_symbol_count", 0)) + payload_count = int(metadata["waveform"]["transmitted_prbs_bit_count"]) + required = len(marker_bits) + pilot_count + payload_count + start = int(found["start_symbol_index"]) + symbols = found["symbol_samples"][start : start + required] + if len(symbols) != required: + raise RuntimeError(f"Кадр обрезан в {metadata_path}") + marker_carrier = radio.estimate_carrier_parameters( + symbols[: len(marker_bits)], marker_symbols + ) + symbols = radio.correct_phase_and_frequency( + symbols, float(marker_carrier["constant_phase"]), 0.0 + ) + payload_bits = lab043.prbs11(payload_count, lab043.PRBS11_INITIAL_STATE) + expected = radio.bpsk_modulate(payload_bits) + payload = symbols[len(marker_bits) + pilot_count :] + phase_corrected, residual_cfo_hz, _ = _known_sequence_phase_fit( + payload, expected, lab043.SYMBOL_RATE + ) + normalized, _ = _normalize_symbols(phase_corrected, expected) + return CaptureAnalysis( + name=metadata_path.stem, + metadata=metadata, + raw_bpsk=raw_bpsk, + coarse_corrected=coarse, + matched=matched, + payload_bits=payload_bits, + payload_symbols=payload, + payload_normalized=normalized, + sample_phase=int(found["sample_phase"]), + start_symbol=start, + marker_score=float(found["score"]), + residual_cfo_hz=residual_cfo_hz, + sha256=actual_sha, + ) + + +def triplet_rows( + source: str, + bits: np.ndarray, + values: np.ndarray, + *, + raw_values: np.ndarray | None = None, + fitted_gain: complex = 1.0 + 0.0j, +) -> list[dict]: + decisions = (values.real >= 0.0).astype(np.uint8) + indexes = np.arange(1, len(bits) - 1) + rows: list[dict] = [] + for triplet in TRIPLETS: + pattern = np.fromiter((int(bit) for bit in triplet), dtype=np.uint8) + selected = indexes[ + (bits[indexes - 1] == pattern[0]) + & (bits[indexes] == pattern[1]) + & (bits[indexes + 1] == pattern[2]) + ] + group = values[selected] + raw_group = group if raw_values is None else raw_values[selected] + ideal = 1.0 if pattern[1] else -1.0 + errors = int(np.count_nonzero(decisions[selected] != bits[selected])) + if len(group): + phase_errors = np.angle(group * ideal) + circular_phase = float(np.angle(np.mean(np.exp(1j * phase_errors)))) + metrics = { + "ber": errors / len(selected), + "mean_i": float(np.mean(group.real)), + "mean_q": float(np.mean(group.imag)), + "median_i": float(np.median(group.real)), + "median_q": float(np.median(group.imag)), + "std_i": float(np.std(group.real)), + "std_q": float(np.std(group.imag)), + "mean_amplitude": float(np.mean(np.abs(group))), + "mean_phase_error_deg": math.degrees(circular_phase), + "mean_distance_to_ideal": float(np.mean(np.abs(group - ideal))), + "decision_cross_fraction": errors / len(selected), + "raw_mean_i": float(np.mean(raw_group.real)), + "raw_mean_q": float(np.mean(raw_group.imag)), + } + else: + metrics = { + key: float("nan") + for key in ( + "ber", + "mean_i", + "mean_q", + "median_i", + "median_q", + "std_i", + "std_q", + "mean_amplitude", + "mean_phase_error_deg", + "mean_distance_to_ideal", + "decision_cross_fraction", + "raw_mean_i", + "raw_mean_q", + ) + } + rows.append( + { + "source": source, + "triplet": triplet, + "count": int(len(selected)), + "bit_errors": errors, + **metrics, + "fitted_gain_i": float(fitted_gain.real), + "fitted_gain_q": float(fitted_gain.imag), + } + ) + return rows + + +def tx_stage_rows(bits: np.ndarray) -> list[dict]: + marker_bits, marker_symbols = radio.build_frame_marker() + pilot_bits = lab043.pilot_prbs7() + all_symbols = np.concatenate( + (marker_symbols, radio.bpsk_modulate(pilot_bits), radio.bpsk_modulate(bits)) + ) + expected = radio.bpsk_modulate(bits) + taps = radio.root_raised_cosine_taps( + lab043.LAB042_RRC_ROLLOFF, + lab043.SAMPLES_PER_SYMBOL, + lab043.LAB042_RRC_SPAN_SYMBOLS, + ) + upsampled = np.zeros(len(all_symbols) * lab043.SAMPLES_PER_SYMBOL, dtype=np.complex128) + upsampled[:: lab043.SAMPLES_PER_SYMBOL] = all_symbols + guard = np.zeros( + lab043.LAB042_GUARD_SYMBOL_COUNT * lab043.SAMPLES_PER_SYMBOL, + dtype=np.complex128, + ) + rrc = fftconvolve(np.concatenate((guard, upsampled, guard)), taps, mode="full") + payload_symbol_offset = len(marker_bits) + len(pilot_bits) + first = ( + len(guard) + + (len(taps) - 1) // 2 + + payload_symbol_offset * lab043.SAMPLES_PER_SYMBOL + ) + positions = first + np.arange(len(bits)) * lab043.SAMPLES_PER_SYMBOL + rrc_symbols = rrc[positions] + scaled = rrc_symbols * lab043.LAB042_TX_AMPLITUDE + pluto_complex64 = (scaled * (2**14)).astype(np.complex64) + stages = ( + ("идеальные символы", expected), + ("после TX RRC", rrc_symbols), + ("после масштаба 0.70", scaled), + ("после complex64 и масштаба 2^14", pluto_complex64), + ) + rows: list[dict] = [] + for name, raw_values in stages: + normalized, gain = _normalize_symbols(raw_values, expected) + rows.extend( + triplet_rows(name, bits, normalized, raw_values=raw_values, fitted_gain=gain) + ) + return rows + + +def _fractional_delay(values: np.ndarray, delay_samples: float) -> np.ndarray: + indexes = np.arange(len(values), dtype=np.float64) + source = indexes - delay_samples + real = np.interp(source, indexes, values.real, left=0.0, right=0.0) + imag = np.interp(source, indexes, values.imag, left=0.0, right=0.0) + return real + 1j * imag + + +def ideal_channel_rows(bits: np.ndarray) -> list[dict]: + marker_bits, marker_symbols = radio.build_frame_marker() + pilot_bits = lab043.pilot_prbs7() + pilot_symbols = radio.bpsk_modulate(pilot_bits) + expected = radio.bpsk_modulate(bits) + all_symbols = np.concatenate((marker_symbols, pilot_symbols, expected)) + waveform = lab043.shape_frame_like_lab042(all_symbols) + taps = radio.root_raised_cosine_taps( + lab043.LAB042_RRC_ROLLOFF, + lab043.SAMPLES_PER_SYMBOL, + lab043.LAB042_RRC_SPAN_SYMBOLS, + ) + scale = 0.9999965 + variants: list[tuple[str, np.ndarray]] = [("без искажений", waveform)] + variants.append(("только SRO -3.5 ppm", lab043.radio.resample_for_clock_scale(waveform, scale))) + indexes = np.arange(len(waveform), dtype=np.float64) + cfo = waveform * np.exp(1j * 2.0 * np.pi * 0.5 * indexes / lab043.SAMPLE_RATE_HZ) + variants.append(("только остаточная CFO +0.5 Гц", cfo)) + variants.append(("только дробный тайминг +0.45 отсчёта", _fractional_delay(waveform, 0.45))) + variants.append(("только gain 0.8 exp(j0.4)", waveform * 0.8 * np.exp(0.4j))) + combined = lab043.radio.resample_for_clock_scale(waveform, scale) + combined = _fractional_delay(combined, 0.45) + combined_indexes = np.arange(len(combined), dtype=np.float64) + combined *= 0.8 * np.exp( + 0.4j + 1j * 2.0 * np.pi * 0.5 * combined_indexes / lab043.SAMPLE_RATE_HZ + ) + variants.append(("SRO+CFO+тайминг+gain", combined)) + + rows: list[dict] = [] + for name, channel in variants: + matched = fftconvolve(channel, taps, mode="full") + found = radio.find_radio_frame(matched, marker_symbols, lab043.SAMPLES_PER_SYMBOL) + start = int(found["start_symbol_index"]) + required = len(all_symbols) + symbols = found["symbol_samples"][start : start + required] + marker_carrier = radio.estimate_carrier_parameters( + symbols[: len(marker_bits)], marker_symbols + ) + symbols = radio.correct_phase_and_frequency( + symbols, float(marker_carrier["constant_phase"]), 0.0 + ) + payload = symbols[len(marker_bits) + len(pilot_bits) :] + normalized, gain = _normalize_symbols(payload, expected) + variant_rows = triplet_rows( + name, bits, normalized, raw_values=payload, fitted_gain=gain + ) + for row in variant_rows: + row["sample_phase"] = int(found["sample_phase"]) + row["marker_score"] = float(found["score"]) + rows.extend(variant_rows) + return rows + + +def rrc_rows() -> tuple[list[dict], dict]: + taps = radio.root_raised_cosine_taps( + lab043.LAB042_RRC_ROLLOFF, + lab043.SAMPLES_PER_SYMBOL, + lab043.LAB042_RRC_SPAN_SYMBOLS, + ) + full = np.convolve(taps, taps) + center = len(full) // 2 + normalized = full / full[center] + rows = [] + for offset in range(-5, 6): + rows.append( + { + "symbol_offset": offset, + "sample_offset": offset * lab043.SAMPLES_PER_SYMBOL, + "h_normalized": float(normalized[center + offset * lab043.SAMPLES_PER_SYMBOL]), + } + ) + audit = { + "tx_tap_count": len(taps), + "rx_tap_count": len(taps), + "rolloff": lab043.LAB042_RRC_ROLLOFF, + "span_symbols": lab043.LAB042_RRC_SPAN_SYMBOLS, + "samples_per_symbol": lab043.SAMPLES_PER_SYMBOL, + "symmetry_max_abs_error": float(np.max(np.abs(taps - taps[::-1]))), + "tap_energy": float(np.sum(taps**2)), + "tx_group_delay_samples": (len(taps) - 1) // 2, + "rx_group_delay_samples": (len(taps) - 1) // 2, + "total_group_delay_samples": len(taps) - 1, + "total_group_delay_symbols": (len(taps) - 1) / lab043.SAMPLES_PER_SYMBOL, + "sampling_index_rule": "find_radio_frame: phase 0..119, затем одинаковый индекс phase+n*120 для обоих знаков", + "one_sample_sign_dependent_shift": False, + } + return rows, audit + + +def phase_sweep_rows(capture: CaptureAnalysis) -> list[dict]: + marker_bits, marker_symbols = radio.build_frame_marker() + pilot_count = int(capture.metadata["waveform"].get("pilot_symbol_count", 0)) + expected = radio.bpsk_modulate(capture.payload_bits) + rows: list[dict] = [] + for phase in range(lab043.SAMPLES_PER_SYMBOL): + symbol_samples = capture.matched[phase:: lab043.SAMPLES_PER_SYMBOL] + correlation = np.correlate(symbol_samples, marker_symbols, mode="valid") + energy = np.convolve( + np.abs(symbol_samples) ** 2, np.ones(len(marker_symbols)), mode="valid" + ) + score = np.abs(correlation) / np.sqrt(energy * len(marker_symbols) + 1e-15) + start = int(np.argmax(score)) + required = len(marker_bits) + pilot_count + len(expected) + symbols = symbol_samples[start : start + required] + if len(symbols) != required: + continue + marker_carrier = radio.estimate_carrier_parameters( + symbols[: len(marker_bits)], marker_symbols + ) + symbols = radio.correct_phase_and_frequency( + symbols, float(marker_carrier["constant_phase"]), 0.0 + ) + payload = symbols[len(marker_bits) + pilot_count :] + normalized, _ = _normalize_symbols(payload, expected) + decisions = (normalized.real >= 0.0).astype(np.uint8) + errors = decisions != capture.payload_bits + indexes = np.arange(1, len(expected) - 1) + triplet_labels = np.asarray( + [ + f"{capture.payload_bits[i-1]}{capture.payload_bits[i]}{capture.payload_bits[i+1]}" + for i in indexes + ] + ) + error_indexes = indexes[errors[indexes]] + error_labels = triplet_labels[errors[indexes]] + negative = normalized[capture.payload_bits == 0].real + positive = normalized[capture.payload_bits == 1].real + rows.append( + { + "capture": capture.name, + "phase": phase, + "marker_start_symbol": start, + "marker_score": float(score[start]), + "bit_errors": int(np.count_nonzero(errors)), + "ber": float(np.mean(errors)), + "errors_000": int(np.count_nonzero(error_labels == "000")), + "errors_001": int(np.count_nonzero(error_labels == "001")), + "errors_other": int( + len(error_indexes) + - np.count_nonzero(error_labels == "000") + - np.count_nonzero(error_labels == "001") + ), + "negative_mean_i": float(np.mean(negative)), + "positive_mean_i": float(np.mean(positive)), + "negative_p95_i": float(np.percentile(negative, 95)), + "positive_p05_i": float(np.percentile(positive, 5)), + "eye_opening_i_p05_p95": float( + np.percentile(positive, 5) - np.percentile(negative, 95) + ), + "negative_std_i": float(np.std(negative)), + "positive_std_i": float(np.std(positive)), + } + ) + return rows + + +def dc_iq_rows(capture: CaptureAnalysis) -> list[dict]: + expected = radio.bpsk_modulate(capture.payload_bits) + values = capture.payload_normalized + plus = values[capture.payload_bits == 1] + minus = values[capture.payload_bits == 0] + plus_center = complex(np.mean(plus)) + minus_center = complex(np.mean(minus)) + dc = (plus_center + minus_center) / 2.0 + residual = np.concatenate((plus - plus_center, minus - minus_center)) + covariance = np.cov(np.column_stack((residual.real, residual.imag)), rowvar=False) + eigenvalues = np.linalg.eigvalsh(covariance) + ellipse_ratio = math.sqrt(float(eigenvalues[-1] / max(eigenvalues[0], 1e-15))) + phase_separation = abs( + math.degrees(np.angle(np.exp(1j * (np.angle(plus_center) - np.angle(minus_center))))) + ) + return [ + { + "capture": capture.name, + "raw_bpsk_mean_i": float(np.mean(capture.raw_bpsk).real), + "raw_bpsk_mean_q": float(np.mean(capture.raw_bpsk).imag), + "coarse_before_rrc_mean_i": float(np.mean(capture.coarse_corrected).real), + "coarse_before_rrc_mean_q": float(np.mean(capture.coarse_corrected).imag), + "matched_mean_i": float(np.mean(capture.matched).real), + "matched_mean_q": float(np.mean(capture.matched).imag), + "positive_center_i": plus_center.real, + "positive_center_q": plus_center.imag, + "negative_center_i": minus_center.real, + "negative_center_q": minus_center.imag, + "symbol_dc_i": dc.real, + "symbol_dc_q": dc.imag, + "positive_distance_from_zero": abs(plus_center), + "negative_distance_from_zero": abs(minus_center), + "positive_decision_margin_i": plus_center.real, + "negative_decision_margin_i": -minus_center.real, + "residual_std_i": float(np.std(residual.real)), + "residual_std_q": float(np.std(residual.imag)), + "iq_std_ratio": float(np.std(residual.real) / max(np.std(residual.imag), 1e-15)), + "iq_correlation": float(np.corrcoef(residual.real, residual.imag)[0, 1]), + "ellipse_axis_ratio": ellipse_ratio, + "cluster_phase_separation_deg": phase_separation, + "negative_is_closer_to_boundary": bool(-minus_center.real < plus_center.real), + } + ] + + +def run_length_rows(capture: CaptureAnalysis) -> list[dict]: + bits = capture.payload_bits + values = capture.payload_normalized + decisions = (values.real >= 0.0).astype(np.uint8) + rows: list[dict] = [] + start = 0 + runs: list[tuple[int, int, int]] = [] + for index in range(1, len(bits) + 1): + if index == len(bits) or bits[index] != bits[start]: + runs.append((start, index, int(bits[start]))) + start = index + grouped: dict[tuple[int, str, str], list[int]] = {} + for first, end, bit in runs: + length = end - first + category = str(length) if length <= 6 else "7+" + for index in range(first, end): + if length == 1: + position = "single" + elif index == first: + position = "first" + elif index == end - 1: + position = "last" + else: + position = "middle" + grouped.setdefault((bit, category, position), []).append(index) + for (bit, category, position), selected_list in sorted(grouped.items()): + selected = np.asarray(selected_list, dtype=np.int64) + group = values[selected] + ideal = 1.0 if bit else -1.0 + phase = np.angle(group * ideal) + rows.append( + { + "capture": capture.name, + "bit": bit, + "run_length": category, + "position": position, + "symbol_count": len(selected), + "mean_i": float(np.mean(group.real)), + "mean_q": float(np.mean(group.imag)), + "mean_amplitude": float(np.mean(np.abs(group))), + "mean_signed_amplitude": float(np.mean(group.real * ideal)), + "mean_phase_error_deg": math.degrees( + float(np.angle(np.mean(np.exp(1j * phase)))) + ), + "ber": float(np.mean(decisions[selected] != bits[selected])), + } + ) + return rows + + +def spectrum_rows(captures: list[CaptureAnalysis], bits: np.ndarray) -> list[dict]: + marker_bits, marker_symbols = radio.build_frame_marker() + pilot_bits = lab043.pilot_prbs7() + tx_symbols = np.concatenate( + (marker_symbols, radio.bpsk_modulate(pilot_bits), radio.bpsk_modulate(bits)) + ) + tx = lab043.shape_frame_like_lab042(tx_symbols) + sources: list[tuple[str, np.ndarray, float]] = [("чистый TX waveform", tx, lab043.SAMPLE_RATE_HZ)] + sources.extend( + (f"{capture.name}: RX после грубой CFO", capture.coarse_corrected, float(capture.metadata["actual_sample_rate_hz"])) + for capture in captures + ) + rows: list[dict] = [] + for name, values, fs in sources: + frequencies, psd = welch( + values, + fs=fs, + window="hann", + nperseg=min(65536, len(values)), + return_onesided=False, + scaling="density", + ) + frequencies = np.fft.fftshift(frequencies) + psd = np.fft.fftshift(psd) + total = float(np.sum(psd)) + 1e-30 + near_reference = (np.abs(frequencies) >= 2_000) & (np.abs(frequencies) <= 10_000) + dc_index = int(np.argmin(np.abs(frequencies))) + row = { + "source": name, + "mean_i": float(np.mean(values).real), + "mean_q": float(np.mean(values).imag), + "mean_amplitude": float(np.mean(np.abs(values))), + "dc_bin_to_2_10khz_median_db": float( + 10.0 * np.log10((psd[dc_index] + 1e-30) / (np.median(psd[near_reference]) + 1e-30)) + ), + } + for band in (100, 500, 1_000, 5_000, 20_000): + row[f"power_fraction_abs_f_below_{band}_hz"] = float( + np.sum(psd[np.abs(frequencies) < band]) / total + ) + rows.append(row) + return rows + + +def symbol_frequency_rows(captures: list[CaptureAnalysis]) -> list[dict]: + """Оценить передаточную характеристику по известным символам без коррекции АЧХ.""" + + rows: list[dict] = [] + bands = ((10, 100), (100, 500), (500, 1_000), (1_000, 5_000), (5_000, 9_000)) + for capture in captures: + expected = radio.bpsk_modulate(capture.payload_bits).astype(np.complex128) + received = capture.payload_normalized + frequencies, input_psd = welch( + expected, + fs=lab043.SYMBOL_RATE, + window="hann", + nperseg=1024, + noverlap=512, + return_onesided=False, + scaling="density", + ) + _, cross_psd = csd( + expected, + received, + fs=lab043.SYMBOL_RATE, + window="hann", + nperseg=1024, + noverlap=512, + return_onesided=False, + scaling="density", + ) + response = np.abs(cross_psd / (input_psd + 1e-30)) + for low, high in bands: + selected = (np.abs(frequencies) >= low) & (np.abs(frequencies) < high) + rows.append( + { + "capture": capture.name, + "band_low_hz": low, + "band_high_hz": high, + "mean_transfer_magnitude": float(np.mean(response[selected])), + "median_transfer_magnitude": float(np.median(response[selected])), + "mean_transfer_db": float( + 20.0 * np.log10(np.mean(response[selected]) + 1e-30) + ), + } + ) + return rows + + +def fitted_symbol_channel_rows( + captures: list[CaptureAnalysis], half_span: int = 8 +) -> tuple[list[dict], list[dict], list[dict]]: + """Оценить на первой половине и проверить на второй gain/DC/FIR-модели.""" + + summaries: list[dict] = [] + coefficient_rows: list[dict] = [] + triplets: list[dict] = [] + lags = np.arange(-half_span, half_span + 1) + for capture in captures: + expected = radio.bpsk_modulate(capture.payload_bits).astype(np.complex128) + centers = np.arange(half_span, len(expected) - half_span) + fir_matrix = np.column_stack([expected[centers - lag] for lag in lags]) + target = capture.payload_normalized[centers] + split = len(centers) // 2 + train = slice(0, split) + validation = slice(split, None) + models = { + "gain": fir_matrix[:, [half_span]], + "gain+DC": np.column_stack((fir_matrix[:, half_span], np.ones(len(centers)))), + "FIR": fir_matrix, + "FIR+DC": np.column_stack((fir_matrix, np.ones(len(centers)))), + } + validation_bits = capture.payload_bits[centers[validation]] + for model_name, matrix in models.items(): + coefficients, *_ = np.linalg.lstsq(matrix[train], target[train], rcond=None) + predicted = matrix[validation] @ coefficients + actual = target[validation] + decisions = (predicted.real >= 0.0).astype(np.uint8) + errors = int(np.count_nonzero(decisions != validation_bits)) + actual_rmse = float(np.sqrt(np.mean(np.abs(actual - predicted) ** 2))) + summaries.append( + { + "capture": capture.name, + "model": model_name, + "training_symbols": split, + "validation_symbols": len(predicted), + "validation_rmse": actual_rmse, + "predicted_bit_errors": errors, + "predicted_ber": errors / len(predicted), + "predicted_mean_i": float(np.mean(predicted).real), + "predicted_mean_q": float(np.mean(predicted).imag), + } + ) + source = f"{capture.name}: {model_name}, проверочная половина" + triplets.extend(triplet_rows(source, validation_bits, predicted)) + if model_name == "gain": + coefficient_rows.append( + { + "capture": capture.name, + "model": model_name, + "term": "gain", + "lag_symbols": 0, + "coefficient_i": float(coefficients[0].real), + "coefficient_q": float(coefficients[0].imag), + "magnitude": float(abs(coefficients[0])), + } + ) + elif model_name == "gain+DC": + for term, coefficient in zip(("gain", "DC"), coefficients): + coefficient_rows.append( + { + "capture": capture.name, + "model": model_name, + "term": term, + "lag_symbols": 0 if term == "gain" else "", + "coefficient_i": float(coefficient.real), + "coefficient_q": float(coefficient.imag), + "magnitude": float(abs(coefficient)), + } + ) + else: + fir_coefficients = coefficients[: len(lags)] + for lag, coefficient in zip(lags, fir_coefficients): + coefficient_rows.append( + { + "capture": capture.name, + "model": model_name, + "term": "tap", + "lag_symbols": int(lag), + "coefficient_i": float(coefficient.real), + "coefficient_q": float(coefficient.imag), + "magnitude": float(abs(coefficient)), + } + ) + if model_name == "FIR+DC": + coefficient = coefficients[-1] + coefficient_rows.append( + { + "capture": capture.name, + "model": model_name, + "term": "DC", + "lag_symbols": "", + "coefficient_i": float(coefficient.real), + "coefficient_q": float(coefficient.imag), + "magnitude": float(abs(coefficient)), + } + ) + return summaries, coefficient_rows, triplets + + +def write_csv(path: Path, rows: list[dict]) -> None: + if not rows: + raise ValueError(f"Нет строк для {path}") + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +def save_plots(output_dir: Path, phase_rows: list[dict], run_rows: list[dict]) -> None: + figure, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True) + for capture in sorted({row["capture"] for row in phase_rows}): + selected = [row for row in phase_rows if row["capture"] == capture] + axes[0].plot([row["phase"] for row in selected], [row["ber"] for row in selected], label=capture) + axes[1].plot( + [row["phase"] for row in selected], + [row["eye_opening_i_p05_p95"] for row in selected], + label=capture, + ) + axes[0].set_ylabel("Доля ошибок") + axes[0].set_title("Lab043: зависимость от фазы символьной выборки") + axes[0].grid(True, alpha=0.3) + axes[0].legend(fontsize=8) + axes[1].set_xlabel("Фаза, отсчётов из 120") + axes[1].set_ylabel("Численное раскрытие глаза по I") + axes[1].grid(True, alpha=0.3) + figure.tight_layout() + figure.savefig(output_dir / "phase_sweep.png", dpi=150) + plt.close(figure) + + figure, axis = plt.subplots(figsize=(10, 5)) + categories = ["1", "2", "3", "4", "5", "6", "7+"] + for capture in sorted({row["capture"] for row in run_rows}): + for bit in (0, 1): + values = [] + for category in categories: + chosen = [ + row for row in run_rows + if row["capture"] == capture and row["bit"] == bit and row["run_length"] == category + ] + count = sum(row["symbol_count"] for row in chosen) + values.append( + sum(row["mean_signed_amplitude"] * row["symbol_count"] for row in chosen) / count + if count else np.nan + ) + axis.plot(categories, values, marker="o", label=f"{capture}, бит {bit}") + axis.set_title("Lab043: уровень символов и длина серии") + axis.set_xlabel("Длина серии одинаковых битов") + axis.set_ylabel("Средняя знаковая амплитуда") + axis.grid(True, alpha=0.3) + axis.legend(fontsize=8) + figure.tight_layout() + figure.savefig(output_dir / "run_length_dependence.png", dpi=150) + plt.close(figure) + + +def _summary_report( + captures: list[CaptureAnalysis], + capture_triplets: list[dict], + tx_triplets: list[dict], + ideal_triplets: list[dict], + phase_rows: list[dict], + dc_rows: list[dict], + run_rows: list[dict], + rrc_audit: dict, + rrc_impulse: list[dict], + spectra: list[dict], + symbol_frequency: list[dict], + channel_models: list[dict], + channel_coefficients: list[dict], +) -> str: + lines = ["Lab043: офлайн-диагностика асимметрии BPSK 000/001", ""] + lines.append("Исходные данные") + for capture in captures: + lines.append( + f"- {capture.name}: SHA-256 {capture.sha256}; корреляция маркера " + f"{capture.marker_score:.6f}; диагностическая остаточная CFO " + f"{capture.residual_cfo_hz:+.6f} Гц." + ) + lines.append("") + lines.append("Ошибки по тройкам после общей линейной фазовой коррекции по известной PRBS") + for capture in captures: + rows = [row for row in capture_triplets if row["source"] == capture.name] + total = sum(row["bit_errors"] for row in rows) + target = sum(row["bit_errors"] for row in rows if row["triplet"] in {"000", "001"}) + lines.append(f"- {capture.name}: всего {total}, в 000/001 {target} ({target / max(total, 1):.1%}).") + for row in rows: + lines.append( + f" {row['triplet']}: n={row['count']}, ошибок={row['bit_errors']}, " + f"BER={row['ber']:.6f}, mean={row['mean_i']:+.5f}{row['mean_q']:+.5f}j." + ) + lines.append("") + lines.append("Цифровой TX и идеальный канал") + for source in dict.fromkeys(row["source"] for row in tx_triplets): + rows = [row for row in tx_triplets if row["source"] == source] + spread = max(row["mean_amplitude"] for row in rows) - min(row["mean_amplitude"] for row in rows) + lines.append( + f"- {source}: ошибок {sum(row['bit_errors'] for row in rows)}, " + f"размах средних амплитуд по тройкам {spread:.9f}." + ) + for source in dict.fromkeys(row["source"] for row in ideal_triplets): + rows = [row for row in ideal_triplets if row["source"] == source] + target = sum(row["bit_errors"] for row in rows if row["triplet"] in {"000", "001"}) + total = sum(row["bit_errors"] for row in rows) + lines.append(f"- Идеальный канал, {source}: ошибок {total}, из них 000/001 {target}.") + lines.append("") + lines.append("RRC") + lines.append(json.dumps(rrc_audit, ensure_ascii=False, indent=2)) + lines.append("h[kT]: " + ", ".join(f"h[{row['symbol_offset']:+d}T]={row['h_normalized']:+.9e}" for row in rrc_impulse)) + lines.append("") + lines.append("Фаза выборки") + for capture in captures: + rows = [row for row in phase_rows if row["capture"] == capture.name] + selected = next(row for row in rows if row["phase"] == capture.sample_phase) + best = min(rows, key=lambda row: row["bit_errors"]) + lines.append( + f"- {capture.name}: выбранная фаза {capture.sample_phase}, ошибок {selected['bit_errors']} " + f"(000={selected['errors_000']}, 001={selected['errors_001']}, прочие={selected['errors_other']}), " + f"глаз={selected['eye_opening_i_p05_p95']:+.5f}; минимум обзора на фазе {best['phase']}: " + f"{best['bit_errors']} ошибок, глаз={best['eye_opening_i_p05_p95']:+.5f}. Фаза не изменялась." + ) + lines.append("") + lines.append("DC, I/Q и расстояние до границы") + for row in dc_rows: + lines.append( + f"- {row['capture']}: symbol DC={row['symbol_dc_i']:+.5f}{row['symbol_dc_q']:+.5f}j; " + f"margin -1={row['negative_decision_margin_i']:.5f}, +1={row['positive_decision_margin_i']:.5f}; " + f"ellipse={row['ellipse_axis_ratio']:.3f}, corr(I,Q)={row['iq_correlation']:+.3f}." + ) + lines.append("") + lines.append("Серии одинаковых символов") + for capture in captures: + for bit in (0, 1): + compact = [] + for category in ("1", "2", "3", "4", "5", "6", "7+"): + selected = [row for row in run_rows if row["capture"] == capture.name and row["bit"] == bit and row["run_length"] == category] + count = sum(row["symbol_count"] for row in selected) + if count: + amplitude = sum(row["mean_signed_amplitude"] * row["symbol_count"] for row in selected) / count + errors = sum(row["ber"] * row["symbol_count"] for row in selected) + compact.append(f"{category}: A={amplitude:.4f}, BER={errors/count:.4f}") + lines.append(f"- {capture.name}, бит {bit}: " + "; ".join(compact)) + lines.append("") + lines.append("Низкие частоты") + for row in spectra: + lines.append( + f"- {row['source']}: mean={row['mean_i']:+.3e}{row['mean_q']:+.3e}j, " + f"P(|f|<500 Гц)={row['power_fraction_abs_f_below_500_hz']:.6e}, " + f"DC/median(2..10 кГц)={row['dc_bin_to_2_10khz_median_db']:+.2f} дБ." + ) + lines.append("Передаточная характеристика на символьной скорости по известной PRBS") + for row in symbol_frequency: + lines.append( + f"- {row['capture']}, {row['band_low_hz']}..{row['band_high_hz']} Гц: " + f"|H|={row['mean_transfer_magnitude']:.5f} ({row['mean_transfer_db']:+.2f} дБ)." + ) + lines.append("Диагностические модели символьного канала, обучение/проверка 50/50") + for row in channel_models: + lines.append( + f"- {row['capture']}, {row['model']}: RMSE={row['validation_rmse']:.5f}, " + f"предсказано ошибок {row['predicted_bit_errors']}/{row['validation_symbols']} " + f"({row['predicted_ber']:.6f})." + ) + for capture in captures: + dc = next( + row for row in channel_coefficients + if row["capture"] == capture.name and row["model"] == "FIR+DC" and row["term"] == "DC" + ) + taps = [ + row for row in channel_coefficients + if row["capture"] == capture.name and row["model"] == "FIR+DC" and row["term"] == "tap" + ] + center = next(row for row in taps if row["lag_symbols"] == 0) + off_center = sum(row["magnitude"] for row in taps if row["lag_symbols"] != 0) + lines.append( + f"- {capture.name}: FIR+DC: DC={dc['coefficient_i']:+.5f}{dc['coefficient_q']:+.5f}j, " + f"|h0|={center['magnitude']:.5f}, сумма |h[k!=0]|={off_center:.5f}." + ) + lines.append("") + lines.append( + "Интерпретация: таблицы являются измерениями; никакая коррекция DC, новый порог, " + "эквалайзер или новая фаза автоматически не применялись." + ) + lines.append( + "Классификация: B. Ошибки 000/001 отсутствуют в полном цифровом TX-RRC -> RX-RRC " + "тракте и появляются только в реальном IQ. Промежуточная зависимость амплитуды после " + "одного TX-RRC полностью компенсируется согласованным RX-RRC в идеальном канале." + ) + lines.append( + "Наиболее вероятный механизм: совместное действие комплексного смещения середины " + "кластеров и длинной памяти/ISI аппаратного тракта. FIR+DC-модель, обученная на первой " + "половине и проверенная на второй, воспроизводит часть ошибок 000 без шума, но не весь " + "измеренный BER; место внутри Pluto TX, кабеля/аттенюатора или RTL RX по этим данным не разделяется." + ) + lines.append( + "AC coupling отдельно не доказан: падение уровня длинных серий устойчиво, но оценённая " + "низкочастотная АЧХ различается между двумя S и не образует однозначной монотонной " + "высокочастотной характеристики." + ) + lines.append( + "Следующий диагностический шаг, пока не выполненный: сравнительный приём того же S " + "другим приёмником либо loopback-разделение TX/RX с заранее фиксированными параметрами; " + "после локализации отдельно проверить DC/АЧХ." + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--raw-dir", type=Path, default=Path("data/raw/lab043")) + parser.add_argument( + "--output-dir", type=Path, default=Path("data/processed/lab043_diagnostics") + ) + args = parser.parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + captures = [load_capture(args.raw_dir, name) for name in VALID_CAPTURE_NAMES] + bits = captures[0].payload_bits + capture_triplets: list[dict] = [] + for capture in captures: + capture_triplets.extend( + triplet_rows(capture.name, capture.payload_bits, capture.payload_normalized) + ) + tx_triplets = tx_stage_rows(bits) + ideal_triplets = ideal_channel_rows(bits) + rrc_impulse, rrc_audit = rrc_rows() + phase_rows = [row for capture in captures for row in phase_sweep_rows(capture)] + dc_rows = [row for capture in captures for row in dc_iq_rows(capture)] + run_rows = [row for capture in captures for row in run_length_rows(capture)] + spectra = spectrum_rows(captures, bits) + symbol_frequency = symbol_frequency_rows(captures) + channel_models, channel_coefficients, channel_triplets = fitted_symbol_channel_rows(captures) + + write_csv(args.output_dir / "capture_triplets.csv", capture_triplets) + write_csv(args.output_dir / "tx_stages_triplets.csv", tx_triplets) + write_csv(args.output_dir / "ideal_channel_triplets.csv", ideal_triplets) + write_csv(args.output_dir / "rrc_impulse_response.csv", rrc_impulse) + write_csv(args.output_dir / "phase_sweep.csv", phase_rows) + write_csv(args.output_dir / "dc_iq_metrics.csv", dc_rows) + write_csv(args.output_dir / "run_length_metrics.csv", run_rows) + write_csv(args.output_dir / "spectrum_metrics.csv", spectra) + write_csv(args.output_dir / "symbol_frequency_response.csv", symbol_frequency) + write_csv(args.output_dir / "symbol_channel_models.csv", channel_models) + write_csv(args.output_dir / "symbol_channel_coefficients.csv", channel_coefficients) + write_csv(args.output_dir / "symbol_channel_triplets.csv", channel_triplets) + (args.output_dir / "rrc_audit.json").write_text( + json.dumps(rrc_audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + (args.output_dir / "input_manifest.json").write_text( + json.dumps( + [ + { + "metadata_file": f"{capture.name}.json", + "iq_file": capture.metadata["iq_file"], + "verified_sha256": capture.sha256, + } + for capture in captures + ], + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + save_plots(args.output_dir, phase_rows, run_rows) + report = _summary_report( + captures, + capture_triplets, + tx_triplets, + ideal_triplets, + phase_rows, + dc_rows, + run_rows, + rrc_audit, + rrc_impulse, + spectra, + symbol_frequency, + channel_models, + channel_coefficients, + ) + (args.output_dir / "report.txt").write_text(report, encoding="utf-8") + print(report) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_hackrf_continuity.py b/experiments/lab043_hackrf_continuity.py new file mode 100644 index 0000000..d591f90 --- /dev/null +++ b/experiments/lab043_hackrf_continuity.py @@ -0,0 +1,618 @@ +"""Однотонная проверка непрерывности HackRF RX для Lab043. + +Разрешён ровно один непрерывный комплексный тон Pluto+ +50 кГц. Здесь нет +BPSK, PRBS, пакетов, JPEG и HackRF TX. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import threading +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + +from experiments import lab043_hackrf_hardware as hackrf +from experiments import lab043_pluto_to_rtlsdr as lab043 +from experiments import lab043_prbs11_hardware as rtl_hardware + + +TONE_OFFSET_HZ = 50_000.0 +TONE_AMPLITUDE = 0.35 +TONE_SECONDS = 5.0 +RX_LEADING_SECONDS = 0.75 +RX_TRAILING_SECONDS = 0.75 +STABLE_EDGE_SECONDS = 0.25 +CYCLIC_PERIOD_COUNT = 4096 +COARSE_FFT_SAMPLES = 262_144 +TONE_MARGIN_THRESHOLD_DB = 10.0 +BOUNDARY_PHASE_THRESHOLD_RAD = 0.25 +LOCAL_PHASE_RMS_LIMIT_RAD = 0.35 +BOUNDARY_SIDE_SAMPLES = 65_536 +LOCAL_PHASE_BLOCK_SAMPLES = 32_768 +LOCAL_PHASE_HOP_SAMPLES = 8_192 +GLOBAL_PHASE_BLOCK_SAMPLES = 262_144 +GLOBAL_PHASE_HOP_SAMPLES = 65_536 +TRANSITION_BLOCK_SAMPLES = 16_384 +TRANSITION_HOP_SAMPLES = 512 +RAW_DIRECTORY = Path("data/raw/lab043_hackrf") + + +@dataclass(frozen=True) +class TonePhaseFit: + frequency_hz: float + phase_at_reference_rad: float + phase_fit_rms_rad: float + maximum_phase_error_rad: float + projection_count: int + + +def build_periodic_tone(actual_sample_rate_hz: float) -> tuple[np.ndarray, float]: + """Построить циклически непрерывный тон около +50 кГц.""" + + samples_per_cycle = int(round(actual_sample_rate_hz / TONE_OFFSET_HZ)) + sample_count = samples_per_cycle * CYCLIC_PERIOD_COUNT + indexes = np.arange(sample_count, dtype=np.float64) + tone = TONE_AMPLITUDE * np.exp( + 1j * 2.0 * np.pi * CYCLIC_PERIOD_COUNT * indexes / sample_count + ) + actual_tone_hz = CYCLIC_PERIOD_COUNT * actual_sample_rate_hz / sample_count + return tone.astype(np.complex64), float(actual_tone_hz) + + +def _phase_projections( + samples: np.ndarray, + sample_rate_hz: float, + coarse_frequency_hz: float, + global_start_sample: int, + reference_sample: int, + block_samples: int, + hop_samples: int, +) -> tuple[np.ndarray, np.ndarray]: + received = np.asarray(samples, dtype=np.complex64) + if len(received) < block_samples + 2 * hop_samples: + raise ValueError("Участок слишком короток для фазовой модели") + window = np.hanning(block_samples).astype(np.float64) + starts = np.arange(0, len(received) - block_samples + 1, hop_samples) + projections = np.empty(len(starts), dtype=np.complex128) + centers = np.empty(len(starts), dtype=np.float64) + local_indexes = np.arange(block_samples, dtype=np.float64) + for row, start in enumerate(starts): + absolute_start = global_start_sample + int(start) + relative_indexes = absolute_start - reference_sample + local_indexes + oscillator = np.exp( + -1j * 2.0 * np.pi * coarse_frequency_hz * relative_indexes / sample_rate_hz + ) + projections[row] = np.sum(received[start : start + block_samples] * oscillator * window) + centers[row] = ( + absolute_start + (block_samples - 1) / 2.0 - reference_sample + ) / sample_rate_hz + return centers, projections + + +def fit_tone_phase( + samples: np.ndarray, + sample_rate_hz: float, + coarse_frequency_hz: float, + global_start_sample: int, + reference_sample: int, + block_samples: int, + hop_samples: int, +) -> TonePhaseFit: + """Оценить частоту и фазу тона, не пересекая неизвестную границу.""" + + times, projections = _phase_projections( + samples, + sample_rate_hz, + coarse_frequency_hz, + global_start_sample, + reference_sample, + block_samples, + hop_samples, + ) + phases = np.unwrap(np.angle(projections)) + weights = np.abs(projections) ** 2 + design = np.column_stack((np.ones(len(times)), times)) + root_weights = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) + coefficients, *_ = np.linalg.lstsq( + design * root_weights[:, None], phases * root_weights, rcond=None + ) + fitted = design @ coefficients + residuals = phases - fitted + weight_sum = max(float(np.sum(weights)), 1e-30) + rms = math.sqrt(float(np.sum(weights * residuals**2)) / weight_sum) + return TonePhaseFit( + frequency_hz=float(coarse_frequency_hz + coefficients[1] / (2.0 * np.pi)), + phase_at_reference_rad=float(np.angle(np.exp(1j * coefficients[0]))), + phase_fit_rms_rad=float(rms), + maximum_phase_error_rad=float(np.max(np.abs(residuals))), + projection_count=int(len(projections)), + ) + + +def estimate_coarse_tone( + samples: np.ndarray, + sample_rate_hz: float, + fft_samples: int = COARSE_FFT_SAMPLES, +) -> dict: + """Найти единственный тон около |50 кГц| по усреднённому спектру.""" + + received = np.asarray(samples, dtype=np.complex64) + if len(received) < fft_samples: + raise ValueError("Недостаточно отсчётов для фиксированного спектрального окна") + window = np.hanning(fft_samples).astype(np.float64) + accumulated = np.zeros(fft_samples, dtype=np.float64) + segment_count = len(received) // fft_samples + for index in range(segment_count): + segment = received[index * fft_samples : (index + 1) * fft_samples] + spectrum = np.fft.fft(segment * window) + accumulated += np.abs(spectrum) ** 2 + powers = accumulated / segment_count + frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate_hz) + search = (np.abs(frequencies) >= 35_000.0) & (np.abs(frequencies) <= 65_000.0) + candidate_indexes = np.flatnonzero(search) + peak_index = int(candidate_indexes[np.argmax(powers[candidate_indexes])]) + peak_frequency = float(frequencies[peak_index]) + local_noise = search & (np.abs(frequencies - peak_frequency) >= 2_000.0) + noise_floor = float(np.median(powers[local_noise])) + margin_db = 10.0 * math.log10(float(powers[peak_index]) / max(noise_floor, 1e-30)) + resolution_hz = sample_rate_hz / fft_samples + left = (peak_index - 1) % fft_samples + right = (peak_index + 1) % fft_samples + y0, y1, y2 = np.log(np.maximum(powers[[left, peak_index, right]], 1e-300)) + denominator = y0 - 2.0 * y1 + y2 + offset_bins = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator + refined = peak_frequency + float(offset_bins) * resolution_hz + return { + "frequency_hz": float(refined), + "peak_bin_frequency_hz": peak_frequency, + "tone_above_noise_db": float(margin_db), + "noise_floor_power": noise_floor, + "fft_resolution_hz": float(resolution_hz), + "fft_segment_count": int(segment_count), + "credible": bool(margin_db >= TONE_MARGIN_THRESHOLD_DB), + } + + +def _wrap_phase(value: float) -> float: + return float(np.angle(np.exp(1j * value))) + + +def _transition_offset( + samples: np.ndarray, + sample_rate_hz: float, + coarse_frequency_hz: float, + global_start_sample: int, + boundary_sample: int, + before_fit: TonePhaseFit, + jump_rad: float, +) -> dict: + times, projections = _phase_projections( + samples, + sample_rate_hz, + coarse_frequency_hz, + global_start_sample, + boundary_sample, + TRANSITION_BLOCK_SAMPLES, + TRANSITION_HOP_SAMPLES, + ) + before_model = before_fit.phase_at_reference_rad + 2.0 * np.pi * ( + before_fit.frequency_hz - coarse_frequency_hz + ) * times + residual = np.unwrap(np.angle(projections * np.exp(-1j * before_model))) + kernel = np.ones(9, dtype=np.float64) / 9.0 + smoothed = np.convolve(residual, kernel, mode="same") + guard = 4 + usable = slice(guard, len(times) - guard) + target = jump_rad / 2.0 + local_index = int(np.argmin(np.abs(smoothed[usable] - target))) + guard + return { + "transition_offset_samples": float(times[local_index] * sample_rate_hz), + "transition_resolution_samples": TRANSITION_HOP_SAMPLES, + "transition_window_half_width_samples": TRANSITION_BLOCK_SAMPLES // 2, + } + + +def analyze_callback_boundaries( + samples: np.ndarray, + boundaries: tuple[hackrf.HackrfBoundary, ...], + sample_rate_hz: float, + coarse_frequency_hz: float, + stable_start_sample: int, + stable_end_sample: int, + side_samples: int = BOUNDARY_SIDE_SAMPLES, + block_samples: int = LOCAL_PHASE_BLOCK_SAMPLES, + hop_samples: int = LOCAL_PHASE_HOP_SAMPLES, +) -> tuple[list[dict], dict]: + rows: list[dict] = [] + for boundary in boundaries: + sample = int(boundary.accepted_end_sample) + if sample - side_samples < stable_start_sample or sample + side_samples > stable_end_sample: + continue + before = fit_tone_phase( + samples[sample - side_samples : sample], + sample_rate_hz, + coarse_frequency_hz, + sample - side_samples, + sample, + block_samples, + hop_samples, + ) + after = fit_tone_phase( + samples[sample : sample + side_samples], + sample_rate_hz, + coarse_frequency_hz, + sample, + sample, + block_samples, + hop_samples, + ) + jump = _wrap_phase(after.phase_at_reference_rad - before.phase_at_reference_rad) + credible = bool( + before.phase_fit_rms_rad <= LOCAL_PHASE_RMS_LIMIT_RAD + and after.phase_fit_rms_rad <= LOCAL_PHASE_RMS_LIMIT_RAD + ) + confirmed = bool(credible and abs(jump) > BOUNDARY_PHASE_THRESHOLD_RAD) + mean_frequency = 0.5 * (before.frequency_hz + after.frequency_hz) + phase_per_sample = 2.0 * np.pi * mean_frequency / sample_rate_hz + period_samples = sample_rate_hz / abs(mean_frequency) + equivalent_shift = jump / phase_per_sample + row = { + "callback_index": boundary.callback_index, + "boundary_sample": sample, + "buffer_length_bytes": boundary.buffer_length, + "valid_length_bytes": boundary.valid_bytes, + "complex_samples_in_callback": boundary.valid_bytes // 2, + "callback_duration_seconds": boundary.valid_bytes / (2.0 * sample_rate_hz), + "frequency_before_hz": before.frequency_hz, + "frequency_after_hz": after.frequency_hz, + "phase_before_at_boundary_rad": before.phase_at_reference_rad, + "phase_after_at_boundary_rad": after.phase_at_reference_rad, + "expected_phase_per_sample_rad": float(phase_per_sample), + "additional_phase_jump_rad": jump, + "before_phase_fit_rms_rad": before.phase_fit_rms_rad, + "after_phase_fit_rms_rad": after.phase_fit_rms_rad, + "credible": credible, + "confirmed_jump": confirmed, + "equivalent_sample_shift_principal": float(equivalent_shift), + "tone_period_samples": float(period_samples), + "equivalent_sample_shift_ambiguity": "principal + k * tone_period_samples", + } + if confirmed: + transition_radius = 2 * TRANSITION_BLOCK_SAMPLES + local_start = sample - transition_radius + local_end = sample + transition_radius + transition = _transition_offset( + samples[local_start:local_end], + sample_rate_hz, + coarse_frequency_hz, + local_start, + sample, + before, + jump, + ) + row.update(transition) + uncertainty = transition["transition_window_half_width_samples"] + offset = transition["transition_offset_samples"] + row["relation_to_callback_boundary"] = ( + "consistent_with_exact_boundary" + if abs(offset) <= uncertainty + else "delayed_after_boundary" + if offset > uncertainty + else "before_boundary" + ) + else: + row.update( + { + "transition_offset_samples": None, + "transition_resolution_samples": None, + "transition_window_half_width_samples": None, + "relation_to_callback_boundary": "no_confirmed_jump", + } + ) + rows.append(row) + credible_count = sum(bool(row["credible"]) for row in rows) + confirmed_count = sum(bool(row["confirmed_jump"]) for row in rows) + return rows, { + "checked_boundary_count": len(rows), + "credible_boundary_count": credible_count, + "confirmed_jump_count": confirmed_count, + "maximum_absolute_jump_rad": float( + max((abs(row["additional_phase_jump_rad"]) for row in rows), default=float("nan")) + ), + } + + +def analyze_continuity_tone( + samples: np.ndarray, + boundaries: tuple[hackrf.HackrfBoundary, ...], + sample_rate_hz: float, + stable_start_sample: int, + stable_end_sample: int, +) -> dict: + stable = np.asarray(samples[stable_start_sample:stable_end_sample], dtype=np.complex64) + coarse = estimate_coarse_tone(stable, sample_rate_hz) + if not coarse["credible"]: + return { + "classification": "D", + "reason": "Тон не прошёл заранее заданный спектральный порог 10 дБ", + "coarse_tone": coarse, + "boundaries": [], + } + whole = fit_tone_phase( + stable, + sample_rate_hz, + coarse["frequency_hz"], + stable_start_sample, + stable_start_sample, + GLOBAL_PHASE_BLOCK_SAMPLES, + GLOBAL_PHASE_HOP_SAMPLES, + ) + rows, boundary_summary = analyze_callback_boundaries( + samples, + boundaries, + sample_rate_hz, + coarse["frequency_hz"], + stable_start_sample, + stable_end_sample, + ) + thirds = [] + edges = np.linspace(stable_start_sample, stable_end_sample, 4, dtype=int) + for index in range(3): + start, end = int(edges[index]), int(edges[index + 1]) + fit = fit_tone_phase( + samples[start:end], + sample_rate_hz, + coarse["frequency_hz"], + start, + start, + GLOBAL_PHASE_BLOCK_SAMPLES, + GLOBAL_PHASE_HOP_SAMPLES, + ) + thirds.append( + { + "section": index + 1, + "start_sample": start, + "end_sample": end, + **asdict(fit), + } + ) + centers = np.asarray( + [0.5 * (row["start_sample"] + row["end_sample"]) / sample_rate_hz for row in thirds] + ) + frequencies = np.asarray([row["frequency_hz"] for row in thirds]) + drift_hz_per_second = float(np.polyfit(centers, frequencies, 1)[0]) + required_credible = max(10, math.ceil(0.8 * len(rows))) + if len(rows) == 0 or boundary_summary["credible_boundary_count"] < required_credible: + classification = "D" + reason = "Локальная фазовая точность недостаточна для решения о непрерывности" + elif boundary_summary["confirmed_jump_count"]: + classification = "B" + reason = "Подтверждены реальные фазовые скачки в непрерывном HackRF RX" + else: + classification = "A" + reason = "Однотонный контроль непрерывен; прежняя двухтональная проверка была недостоверна" + return { + "classification": classification, + "reason": reason, + "coarse_tone": coarse, + "stable_start_sample": int(stable_start_sample), + "stable_end_sample": int(stable_end_sample), + "stable_sample_count": int(len(stable)), + "stable_duration_seconds": float(len(stable) / sample_rate_hz), + "whole_tone_phase_fit": asdict(whole), + "frequency_by_thirds": thirds, + "frequency_drift_hz_per_second": drift_hz_per_second, + "frequency_span_hz": float(np.ptp(frequencies)), + "boundary_summary": boundary_summary, + "boundaries": rows, + "phase_jump_threshold_rad": BOUNDARY_PHASE_THRESHOLD_RAD, + "local_phase_rms_limit_rad": LOCAL_PHASE_RMS_LIMIT_RAD, + "software_callback_bug_found": False, + } + + +def _monotonic_to_sample( + monotonic_value: float, + capture: hackrf.HackrfCapture, + sample_rate_hz: float, +) -> int: + relative = monotonic_value - capture.diagnostics["stream_started_monotonic"] + boundary = min( + capture.boundaries, + key=lambda row: abs(row.monotonic_seconds - relative), + ) + estimate = boundary.accepted_end_sample + round( + (relative - boundary.monotonic_seconds) * sample_rate_hz + ) + return int(np.clip(estimate, 0, len(capture.samples))) + + +def run_one_continuity_tone(raw_directory: Path = RAW_DIRECTORY) -> dict: + """Выполнить ровно один однотоновый опыт и остановиться.""" + + pluto_preflight = rtl_hardware.preflight_pluto() + hackrf_preflight = hackrf.preflight_hackrf() + pluto, tx_parameters = rtl_hardware.configure_pluto() + actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) + waveform, actual_tx_tone_hz = build_periodic_tone(actual_tx_rate) + device_waveform = lab043.to_pluto_tx_samples(waveform) + waveform_sha256 = hashlib.sha256(waveform.tobytes()).hexdigest() + target_samples = math.ceil( + (RX_LEADING_SECONDS + TONE_SECONDS + RX_TRAILING_SECONDS) + * hackrf.HACKRF_SAMPLE_RATE_HZ + ) + hackrf.HACKRF_EXPECTED_CALLBACK_BYTES // 2 + capture_started = datetime.now(timezone.utc) + capture_holder: list[hackrf.HackrfCapture] = [] + errors: list[str] = [] + tx_times: dict[str, float] = {} + + with hackrf.HackrfRxDevice() as receiver: + rx_parameters = receiver.configure_rx() + + def receive() -> None: + try: + capture_holder.append(receiver.capture(target_samples)) + except Exception as error: # pragma: no cover - аппаратный путь + errors.append(f"RX {type(error).__name__}: {error}") + + rx_thread = threading.Thread(target=receive, daemon=True) + rx_thread.start() + deadline = time.perf_counter() + 5.0 + while time.perf_counter() < deadline: + if receiver.library.hackrf_is_streaming(receiver.device) == 1: + break + if errors: + break + time.sleep(0.01) + else: + raise RuntimeError("HackRF RX не подтвердил непрерывный поток; TX не включён") + time.sleep(RX_LEADING_SECONDS) + pluto.tx_cyclic_buffer = True + pluto.tx_destroy_buffer() + try: + tx_times["tx_call_started_monotonic"] = time.perf_counter() + pluto.tx(device_waveform) + tx_times["tx_confirmed_monotonic"] = time.perf_counter() + time.sleep(TONE_SECONDS) + tx_times["tx_stop_started_monotonic"] = time.perf_counter() + finally: + pluto.tx_destroy_buffer() + tx_times["tx_destroyed_monotonic"] = time.perf_counter() + rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 5.0) + if rx_thread.is_alive(): + raise RuntimeError("Непрерывный поток HackRF не завершился") + if errors: + raise RuntimeError("; ".join(errors)) + if len(capture_holder) != 1: + raise RuntimeError("HackRF не вернул единственный непрерывный массив") + capture = capture_holder[0] + stable_start = _monotonic_to_sample( + tx_times["tx_confirmed_monotonic"] + STABLE_EDGE_SECONDS, + capture, + hackrf.HACKRF_SAMPLE_RATE_HZ, + ) + stable_end = _monotonic_to_sample( + tx_times["tx_stop_started_monotonic"] - STABLE_EDGE_SECONDS, + capture, + hackrf.HACKRF_SAMPLE_RATE_HZ, + ) + overload = { + "peak_magnitude": float(np.max(np.abs(capture.samples))), + "clipped_component_fraction": float( + np.mean( + (np.abs(capture.samples.real * 128.0) >= 127) + | (np.abs(capture.samples.imag * 128.0) >= 127) + ) + ), + } + boundary_rows = [asdict(row) for row in capture.boundaries] + metadata = { + "experiment": "Lab043 HackRF single-tone continuity", + "capture_status": "captured", + "processing_status": "pending", + "processing_error": None, + "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters | { + "serial_number": hackrf_preflight["serial_number"], + "firmware_version": hackrf_preflight["firmware_version"], + "libhackrf_version": hackrf_preflight["libhackrf_version"], + }, + "pluto_preflight": pluto_preflight, + "hackrf_preflight": hackrf_preflight, + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF ANTENNA; antennas removed", + "waveform_sha256": waveform_sha256, + "waveform": { + "kind": "single_complex_tone", + "requested_offset_hz": TONE_OFFSET_HZ, + "actual_periodic_offset_hz": actual_tx_tone_hz, + "amplitude": TONE_AMPLITUDE, + "cyclic_buffer_sample_count": len(waveform), + "cyclic_period_count": CYCLIC_PERIOD_COUNT, + }, + "tx_waveform_duration_seconds": TONE_SECONDS, + "tx_timing": tx_times, + "callback_count": capture.diagnostics["callback_count_total"], + "callback_boundaries": boundary_rows, + "continuous_rx": capture.diagnostics | {"boundaries": boundary_rows}, + "expected_intervals": { + "rx_leading_seconds": RX_LEADING_SECONDS, + "tone_seconds": TONE_SECONDS, + "rx_trailing_seconds": RX_TRAILING_SECONDS, + "stable_edge_seconds": STABLE_EDGE_SECONDS, + "stable_start_sample": stable_start, + "stable_end_sample": stable_end, + }, + "overload": overload, + } + stamp = capture_started.strftime("%Y%m%d_%H%M%S") + base = raw_directory / f"lab043_hackrf_continuity_tone_{stamp}" + iq_path, metadata_path, document = lab043.save_raw_iq_before_processing( + base, + capture.samples, + metadata, + ) + restored = np.load(iq_path, allow_pickle=False) + if hashlib.sha256(iq_path.read_bytes()).hexdigest() != document["iq_sha256"]: + raise RuntimeError("SHA-256 raw изменился перед анализом") + try: + analysis = analyze_continuity_tone( + restored, + capture.boundaries, + hackrf.HACKRF_SAMPLE_RATE_HZ, + stable_start, + stable_end, + ) + except Exception as error: + message = f"{type(error).__name__}: {error}" + lab043.update_processing_metadata( + metadata_path, + processing_status="processing_failed", + processing_error=message, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": document["iq_sha256"], + "processing_status": "processing_failed", + "processing_error": message, + } + lab043.update_processing_metadata( + metadata_path, + processing_status="success", + processing_error=None, + analysis=analysis, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": document["iq_sha256"], + "processing_status": "success", + "tx_parameters": tx_parameters, + "rx_parameters": metadata["rx_parameters"], + "overload": overload, + "continuous_rx": capture.diagnostics, + "analysis": analysis, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--run-one-continuity-tone", action="store_true", required=True) + arguments = parser.parse_args() + if not arguments.run_one_continuity_tone: # pragma: no cover + return 2 + result = run_one_continuity_tone() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["processing_status"] == "success" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_hackrf_counter.py b/experiments/lab043_hackrf_counter.py new file mode 100644 index 0000000..2f7387b --- /dev/null +++ b/experiments/lab043_hackrf_counter.py @@ -0,0 +1,1397 @@ +"""Диагностический цифровой счётчик непрерывности Pluto+ -> HackRF. + +Это отдельный четырёхсостоянийный сигнал. Он не использует BPSK, PRBS11, +маркер, пилот, пакет, CRC или JPEG из основного тракта Lab043. +""" + +from __future__ import annotations + +import argparse +import csv +import functools +import gc +import hashlib +import json +import math +import sys +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +LFSR_DEGREE = 20 +LFSR_POLYNOMIAL = "x^20 + x^3 + 1" +LFSR_INITIAL_STATE = (1 << LFSR_DEGREE) - 1 +LFSR_PERIOD_BITS = (1 << LFSR_DEGREE) - 1 +SYMBOL_PERIOD = LFSR_PERIOD_BITS +MINIMUM_POSITION_SYMBOLS = 10 +VERIFY_WINDOW_SYMBOLS = 32 +MAXIMUM_VERIFY_MISMATCHES = 2 +MINIMUM_STABLE_ANCHORS = 8 +MAXIMUM_ANCHOR_GAP = 64 + +SYMBOL_RATE = 20_000.0 +NOMINAL_SAMPLE_RATE = 2_400_000.0 +HACKRF_CENTER_HZ = 435_000_000 +PLUTO_SAMPLE_RATE_TOLERANCE_HZ = 10.0 +NOMINAL_SAMPLES_PER_SYMBOL = 120 +USEFUL_SECONDS = 5.0 +USEFUL_SYMBOL_COUNT = int(round(SYMBOL_RATE * USEFUL_SECONDS)) +MARGIN_SYMBOL_COUNT = 5_000 +TRANSMITTED_SYMBOL_COUNT = USEFUL_SYMBOL_COUNT + 2 * MARGIN_SYMBOL_COUNT + +QPSK_STATES = np.asarray( + [ + (1.0 + 1.0j) / math.sqrt(2.0), + (1.0 - 1.0j) / math.sqrt(2.0), + (-1.0 + 1.0j) / math.sqrt(2.0), + (-1.0 - 1.0j) / math.sqrt(2.0), + ], + dtype=np.complex64, +) +TX_AMPLITUDE = 0.35 +FOURTH_POWER_MINIMUM_HZ = 200.0 +FOURTH_POWER_MAXIMUM_HZ = 40_000.0 +TIMING_WINDOW_SYMBOLS = 2_000 +TIMING_WINDOW_COUNT = 25 +PHASE_TRACK_BLOCK_SYMBOLS = 128 +HACKRF_LNA_GAIN_DB = 8 +HACKRF_VGA_GAIN_DB = 8 +SIGNAL_POWER_BLOCK_SAMPLES = 600_000 +RX_LEADING_SECONDS = 0.75 +RX_TRAILING_SECONDS = 0.75 +FIXED_TX_UPLOAD_MARGIN_SECONDS = 2.0 +RAW_DIRECTORY = Path("data/raw/lab043_hackrf") +PROCESSED_DIRECTORY = Path("data/processed/lab043_hackrf_counter") + + +@dataclass(frozen=True) +class AnchorRun: + offset: int + first_anchor: int + last_anchor: int + anchor_count: int + first_sequence_position: int + last_sequence_position: int + + +@functools.lru_cache(maxsize=1) +def prbs20_period_bits() -> np.ndarray: + """Вернуть один полный период рекуррентной последовательности PRBS20.""" + + bits = np.empty(LFSR_PERIOD_BITS + LFSR_DEGREE, dtype=np.uint8) + bits[:LFSR_DEGREE] = 1 + for index in range(LFSR_DEGREE, len(bits)): + bits[index] = bits[index - 17] ^ bits[index - 20] + if not np.array_equal(bits[LFSR_PERIOD_BITS:], bits[:LFSR_DEGREE]): + raise RuntimeError("Полином PRBS20 не вернулся в начальное состояние") + period = bits[:LFSR_PERIOD_BITS].copy() + period.flags.writeable = False + return period + + +@functools.lru_cache(maxsize=1) +def counter_symbol_period() -> np.ndarray: + """Сгруппировать пары битов PRBS20 в четыре диагностических состояния.""" + + bits = prbs20_period_bits() + symbol_indexes = np.arange(SYMBOL_PERIOD, dtype=np.int64) + first = bits[(2 * symbol_indexes) % LFSR_PERIOD_BITS] + second = bits[(2 * symbol_indexes + 1) % LFSR_PERIOD_BITS] + symbols = ((first << 1) | second).astype(np.uint8) + symbols.flags.writeable = False + return symbols + + +def counter_symbol_ids(start_position: int, count: int) -> np.ndarray: + if count <= 0: + raise ValueError("Число символов должно быть положительным") + positions = (int(start_position) + np.arange(count, dtype=np.int64)) % SYMBOL_PERIOD + return counter_symbol_period()[positions].copy() + + +def counter_qpsk_symbols(start_position: int, count: int) -> np.ndarray: + return QPSK_STATES[counter_symbol_ids(start_position, count)] + + +@functools.lru_cache(maxsize=1) +def bit_state_to_position() -> np.ndarray: + """Построить прямую таблицу: 20-битное состояние -> битовая позиция.""" + + bits = prbs20_period_bits() + mask = (1 << LFSR_DEGREE) - 1 + state = 0 + for bit in bits[:LFSR_DEGREE]: + state = (state << 1) | int(bit) + positions = np.full(1 << LFSR_DEGREE, -1, dtype=np.int32) + for position in range(LFSR_PERIOD_BITS): + positions[state] = position + state = ((state << 1) & mask) | int( + bits[(position + LFSR_DEGREE) % LFSR_PERIOD_BITS] + ) + positions.flags.writeable = False + return positions + + +def locate_counter_anchors( + received_symbol_ids: np.ndarray, + verification_symbols: int = VERIFY_WINDOW_SYMBOLS, + maximum_mismatches: int = MAXIMUM_VERIFY_MISMATCHES, +) -> dict[str, np.ndarray]: + """Найти достоверные локальные позиции по длинным окнам счётчика.""" + + received = np.asarray(received_symbol_ids, dtype=np.uint8) + if received.ndim != 1 or np.any(received > 3): + raise ValueError("Диагностические символы должны быть одномерными числами 0..3") + if verification_symbols < MINIMUM_POSITION_SYMBOLS: + raise ValueError("Проверочное окно короче минимального состояния PRBS20") + candidate_count = len(received) - verification_symbols + 1 + if candidate_count <= 0: + return { + "received_index": np.empty(0, dtype=np.int64), + "sequence_position": np.empty(0, dtype=np.int64), + "mismatches": np.empty(0, dtype=np.int16), + } + + states = np.zeros(candidate_count, dtype=np.uint32) + for offset in range(MINIMUM_POSITION_SYMBOLS): + states = (states << 2) | received[offset : offset + candidate_count] + bit_positions = bit_state_to_position()[states] + valid = bit_positions >= 0 + inverse_two = (LFSR_PERIOD_BITS + 1) // 2 + sequence_positions = ( + bit_positions.astype(np.int64) * inverse_two + ) % SYMBOL_PERIOD + mismatches = np.zeros(candidate_count, dtype=np.int16) + reference = counter_symbol_period() + for offset in range(verification_symbols): + expected = reference[(sequence_positions + offset) % SYMBOL_PERIOD] + mismatches += received[offset : offset + candidate_count] != expected + accepted = valid & (mismatches <= maximum_mismatches) + return { + "received_index": np.flatnonzero(accepted).astype(np.int64), + "sequence_position": sequence_positions[accepted].astype(np.int64), + "mismatches": mismatches[accepted], + } + + +def stable_anchor_runs(anchors: dict[str, np.ndarray]) -> list[AnchorRun]: + indexes = anchors["received_index"] + positions = anchors["sequence_position"] + if len(indexes) == 0: + return [] + offsets = (positions - indexes) % SYMBOL_PERIOD + starts = [0] + for index in range(1, len(indexes)): + if ( + offsets[index] != offsets[index - 1] + or indexes[index] - indexes[index - 1] > MAXIMUM_ANCHOR_GAP + ): + starts.append(index) + starts.append(len(indexes)) + runs = [] + for begin, end in zip(starts[:-1], starts[1:]): + if end - begin < MINIMUM_STABLE_ANCHORS: + continue + runs.append( + AnchorRun( + offset=int(offsets[begin]), + first_anchor=int(indexes[begin]), + last_anchor=int(indexes[end - 1]), + anchor_count=end - begin, + first_sequence_position=int(positions[begin]), + last_sequence_position=int(positions[end - 1]), + ) + ) + return runs + + +def _signed_period_delta(after: int, before: int) -> int: + delta = (int(after) - int(before)) % SYMBOL_PERIOD + if delta > SYMBOL_PERIOD // 2: + delta -= SYMBOL_PERIOD + return int(delta) + + +def _best_breakpoint( + received: np.ndarray, + before_offset: int, + after_offset: int, + lower: int, + upper: int, +) -> int: + reference = counter_symbol_period() + start = max(0, lower - VERIFY_WINDOW_SYMBOLS) + stop = min(len(received), upper + VERIFY_WINDOW_SYMBOLS) + indexes = np.arange(start, stop, dtype=np.int64) + before_errors = received[start:stop] != reference[(indexes + before_offset) % SYMBOL_PERIOD] + after_errors = received[start:stop] != reference[(indexes + after_offset) % SYMBOL_PERIOD] + before_prefix = np.concatenate(([0], np.cumsum(before_errors, dtype=np.int64))) + after_prefix = np.concatenate(([0], np.cumsum(after_errors, dtype=np.int64))) + candidates = np.arange(max(start, lower), min(stop, upper) + 1, dtype=np.int64) + local = candidates - start + costs = before_prefix[local] + (after_prefix[-1] - after_prefix[local]) + return int(candidates[int(np.argmin(costs))]) + + +def _event_type( + received: np.ndarray, + breakpoint: int, + delta: int, + before_offset: int, +) -> str: + if delta > 0: + return "пропуск" + count = -delta + reference = counter_symbol_period() + search_start = max(0, breakpoint - VERIFY_WINDOW_SYMBOLS) + search_stop = min( + len(received) - count - VERIFY_WINDOW_SYMBOLS, + breakpoint + VERIFY_WINDOW_SYMBOLS, + ) + after_offset = (before_offset + delta) % SYMBOL_PERIOD + for insertion_start in range(search_start, search_stop + 1): + before_begin = max(0, insertion_start - VERIFY_WINDOW_SYMBOLS) + before_indexes = np.arange(before_begin, insertion_start, dtype=np.int64) + after_begin = insertion_start + count + after_end = min(len(received), after_begin + VERIFY_WINDOW_SYMBOLS) + after_indexes = np.arange(after_begin, after_end, dtype=np.int64) + before_mismatches = np.sum( + received[before_begin:insertion_start] + != reference[(before_indexes + before_offset) % SYMBOL_PERIOD] + ) + after_mismatches = np.sum( + received[after_begin:after_end] + != reference[(after_indexes + after_offset) % SYMBOL_PERIOD] + ) + if ( + before_mismatches > MAXIMUM_VERIFY_MISMATCHES + or after_mismatches > MAXIMUM_VERIFY_MISMATCHES + ): + continue + expected_position = (insertion_start + before_offset) % SYMBOL_PERIOD + repeated_positions = ( + expected_position - count + np.arange(count, dtype=np.int64) + ) % SYMBOL_PERIOD + if np.array_equal( + received[insertion_start : insertion_start + count], + reference[repeated_positions], + ): + return "повтор" + return "вставка" + + +def analyze_counter_symbols(received_symbol_ids: np.ndarray) -> dict: + """Построить карту цифровой позиции и отделить ошибки от скачков.""" + + received = np.asarray(received_symbol_ids, dtype=np.uint8) + anchors = locate_counter_anchors(received) + runs = stable_anchor_runs(anchors) + if not runs: + return { + "classification": "D", + "reason": "Нет достоверного участка цифрового счётчика", + "anchor_count": int(len(anchors["received_index"])), + "stable_run_count": 0, + "recovered_symbol_count": 0, + "ordinary_symbol_error_count": None, + "events": [], + "runs": [], + } + + events = [] + for before, after in zip(runs[:-1], runs[1:]): + delta = _signed_period_delta(after.offset, before.offset) + if delta == 0: + continue + breakpoint = _best_breakpoint( + received, + before.offset, + after.offset, + before.last_anchor, + after.first_anchor + VERIFY_WINDOW_SYMBOLS, + ) + expected_position = (breakpoint + before.offset) % SYMBOL_PERIOD + actual_position = (breakpoint + after.offset) % SYMBOL_PERIOD + event_type = _event_type(received, breakpoint, delta, before.offset) + events.append( + { + "received_symbol_index": breakpoint, + "expected_state_number": int(counter_symbol_period()[expected_position]), + "recognized_state_number": ( + int(received[breakpoint]) if breakpoint < len(received) else None + ), + "expected_next_position": int(expected_position), + "actual_next_position": int(actual_position), + "position_delta_symbols": delta, + "minimum_confirmed_gap_symbols": abs(delta), + "type": event_type, + } + ) + + covered = np.zeros(len(received), dtype=bool) + expected_ids = np.zeros(len(received), dtype=np.uint8) + for run in runs: + start = run.first_anchor + stop = min(len(received), run.last_anchor + VERIFY_WINDOW_SYMBOLS) + indexes = np.arange(start, stop, dtype=np.int64) + covered[start:stop] = True + expected_ids[start:stop] = counter_symbol_period()[ + (indexes + run.offset) % SYMBOL_PERIOD + ] + for event in events: + center = int(event["received_symbol_index"]) + covered[ + max(0, center - VERIFY_WINDOW_SYMBOLS) : min( + len(received), center + VERIFY_WINDOW_SYMBOLS + ) + ] = False + ordinary_errors = int(np.sum(covered & (received != expected_ids))) + recovered_count = int(np.sum(covered)) + classification = "B" if events else "A" + return { + "classification": classification, + "reason": ( + "Подтверждены скачки цифровой позиции" + if events + else "Восстановленная цифровая последовательность непрерывна" + ), + "anchor_count": int(len(anchors["received_index"])), + "stable_run_count": len(runs), + "recovered_symbol_count": recovered_count, + "ordinary_symbol_error_count": ordinary_errors, + "events": events, + "runs": [run.__dict__ for run in runs], + } + + +def build_counter_waveform(actual_sample_rate_hz: float) -> tuple[np.ndarray, dict]: + """Построить прямоугольный четырёхсостоянийный диагностический сигнал.""" + + if not np.isfinite(actual_sample_rate_hz) or actual_sample_rate_hz <= 0.0: + raise ValueError("Фактическая частота дискретизации должна быть положительной") + samples_per_symbol = int(round(actual_sample_rate_hz / SYMBOL_RATE)) + if samples_per_symbol != NOMINAL_SAMPLES_PER_SYMBOL: + raise RuntimeError("Фактическая Fs не даёт заранее выбранные 120 отсчётов/символ") + symbols = counter_qpsk_symbols(0, TRANSMITTED_SYMBOL_COUNT) + waveform = np.repeat(symbols, samples_per_symbol) + waveform = (TX_AMPLITUDE * waveform).astype(np.complex64) + actual_symbol_rate = actual_sample_rate_hz / samples_per_symbol + return waveform, { + "samples_per_symbol": samples_per_symbol, + "requested_symbol_rate": SYMBOL_RATE, + "actual_symbol_rate": float(actual_symbol_rate), + "margin_symbol_count_each_side": MARGIN_SYMBOL_COUNT, + "useful_symbol_count": USEFUL_SYMBOL_COUNT, + "transmitted_symbol_count": TRANSMITTED_SYMBOL_COUNT, + "useful_duration_seconds": float(USEFUL_SYMBOL_COUNT / actual_symbol_rate), + "total_duration_seconds": float(len(waveform) / actual_sample_rate_hz), + } + + +def estimate_qpsk_cfo_fourth_power( + samples: np.ndarray, + sample_rate_hz: float, + fft_samples: int = 262_144, + minimum_fourth_power_hz: float = FOURTH_POWER_MINIMUM_HZ, + maximum_fourth_power_hz: float = FOURTH_POWER_MAXIMUM_HZ, +) -> dict: + """Оценить частотное смещение по четвёртой степени QPSK-сигнала.""" + + received = np.asarray(samples, dtype=np.complex64) + block = min(int(fft_samples), 1 << (len(received).bit_length() - 1)) + if block < 4096: + raise ValueError("Слишком мало отсчётов для оценки частотного смещения") + window = np.hanning(block).astype(np.float32) + accumulated = np.zeros(block, dtype=np.float64) + block_count = len(received) // block + for index in range(block_count): + part = received[index * block : (index + 1) * block] + spectrum = np.fft.fft((part**4) * window) + accumulated += np.abs(spectrum) ** 2 + frequencies = np.fft.fftfreq(block, d=1.0 / sample_rate_hz) + search = ( + (np.abs(frequencies) >= minimum_fourth_power_hz) + & (np.abs(frequencies) <= maximum_fourth_power_hz) + ) + indexes = np.flatnonzero(search) + peak = int(indexes[np.argmax(accumulated[indexes])]) + resolution = sample_rate_hz / block + left, right = (peak - 1) % block, (peak + 1) % block + y0, y1, y2 = np.log(np.maximum(accumulated[[left, peak, right]], 1e-300)) + denominator = y0 - 2.0 * y1 + y2 + fractional = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator + fourth_frequency = float(frequencies[peak] + fractional * resolution) + local = search & (np.abs(frequencies - frequencies[peak]) >= 2_000.0) + noise = float(np.median(accumulated[local])) + margin = 10.0 * math.log10(float(accumulated[peak]) / max(noise, 1e-300)) + return { + "carrier_offset_hz": fourth_frequency / 4.0, + "fourth_power_peak_hz": fourth_frequency, + "fft_resolution_hz_before_division": float(resolution), + "peak_above_noise_db": float(margin), + "fft_block_count": int(block_count), + } + + +def correct_frequency( + samples: np.ndarray, + sample_rate_hz: float, + frequency_hz: float, +) -> np.ndarray: + received = np.asarray(samples, dtype=np.complex64) + result = np.empty_like(received) + chunk = 1_000_000 + for start in range(0, len(received), chunk): + stop = min(len(received), start + chunk) + indexes = np.arange(start, stop, dtype=np.float64) + oscillator = np.exp(-1j * 2.0 * np.pi * frequency_hz * indexes / sample_rate_hz) + result[start:stop] = received[start:stop] * oscillator + return result + + +def _best_integer_timing_phase(window: np.ndarray, samples_per_symbol: int) -> int: + values = np.asarray(window, dtype=np.complex64) + cumulative = np.concatenate( + (np.zeros(1, dtype=np.complex128), np.cumsum(values, dtype=np.complex128)) + ) + metrics = np.full(samples_per_symbol, -np.inf, dtype=np.float64) + for phase in range(samples_per_symbol): + starts = np.arange(phase, len(values) - samples_per_symbol + 1, samples_per_symbol) + ends = starts + samples_per_symbol + if len(starts) < 8: + continue + means = (cumulative[ends] - cumulative[starts]) / samples_per_symbol + metrics[phase] = float(np.mean(np.abs(means) ** 2)) + return int(np.argmax(metrics)) + + +def estimate_samples_per_symbol( + samples: np.ndarray, + nominal_samples_per_symbol: int, +) -> dict: + """Оценить линейный дрейф символьных границ по 25 фиксированным окнам.""" + + received = np.asarray(samples, dtype=np.complex64) + window_samples = TIMING_WINDOW_SYMBOLS * nominal_samples_per_symbol + centers = np.linspace( + int(0.18 * len(received)), + int(0.82 * len(received)), + TIMING_WINDOW_COUNT, + dtype=np.int64, + ) + absolute_boundaries = [] + for center in centers: + start = int(np.clip(center - window_samples // 2, 0, len(received) - window_samples)) + phase = _best_integer_timing_phase( + received[start : start + window_samples], nominal_samples_per_symbol + ) + absolute_boundaries.append(start + phase) + boundaries = np.asarray(absolute_boundaries, dtype=np.float64) + residues = np.mod(boundaries, nominal_samples_per_symbol) + unwrapped = np.unwrap(2.0 * np.pi * residues / nominal_samples_per_symbol) + unwrapped_samples = unwrapped * nominal_samples_per_symbol / (2.0 * np.pi) + approximate_symbols = np.rint( + (boundaries - boundaries[0]) / nominal_samples_per_symbol + ).astype(np.int64) + adjusted_boundaries = ( + boundaries + + unwrapped_samples + - residues + ) + design = np.column_stack((np.ones(len(approximate_symbols)), approximate_symbols)) + coefficients, *_ = np.linalg.lstsq(design, adjusted_boundaries, rcond=None) + estimated = float(coefficients[1]) + residuals = adjusted_boundaries - design @ coefficients + return { + "samples_per_symbol": estimated, + "timing_intercept_sample": float(coefficients[0]), + "timing_fit_rms_samples": float(np.sqrt(np.mean(residuals**2))), + "timing_window_count": len(centers), + } + + +def integrate_symbol_grid( + samples: np.ndarray, + samples_per_symbol: float, + timing_intercept_sample: float, +) -> tuple[np.ndarray, np.ndarray]: + received = np.asarray(samples, dtype=np.complex64) + first_index = math.floor(-timing_intercept_sample / samples_per_symbol) - 1 + last_index = math.ceil((len(received) - timing_intercept_sample) / samples_per_symbol) + 1 + grid = timing_intercept_sample + np.arange(first_index, last_index + 1) * samples_per_symbol + boundaries = np.rint(grid).astype(np.int64) + boundaries = np.unique(boundaries[(boundaries >= 0) & (boundaries <= len(received))]) + if len(boundaries) < 3: + raise RuntimeError("Не удалось построить символьную сетку") + starts = boundaries[:-1] + lengths = np.diff(boundaries) + sums = np.add.reduceat(received, starts) + sums[-1] = np.sum(received[starts[-1] : boundaries[-1]]) + symbols = sums / lengths + centers = starts.astype(np.float64) + lengths / 2.0 + return symbols.astype(np.complex64), centers + + +def correct_qpsk_phase_track(symbols: np.ndarray) -> tuple[np.ndarray, dict]: + values = np.asarray(symbols, dtype=np.complex64) + block = PHASE_TRACK_BLOCK_SYMBOLS + block_count = len(values) // block + if block_count < 10: + raise ValueError("Недостаточно символов для фазовой модели") + trimmed = values[: block_count * block].reshape(block_count, block) + fourth = -np.sum(trimmed.astype(np.complex128) ** 4, axis=1) + centers = np.arange(block_count, dtype=np.float64) * block + (block - 1) / 2.0 + select = (centers >= 0.20 * len(values)) & (centers <= 0.80 * len(values)) + phases = np.unwrap(np.angle(fourth[select])) + weights = np.abs(fourth[select]) ** 2 + design = np.column_stack((np.ones(np.sum(select)), centers[select])) + root = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) + coefficients, *_ = np.linalg.lstsq( + design * root[:, None], phases * root, rcond=None + ) + residuals = phases - design @ coefficients + phase = (coefficients[0] + coefficients[1] * np.arange(len(values))) / 4.0 + corrected = values * np.exp(-1j * phase) + return corrected.astype(np.complex64), { + "residual_frequency_rad_per_symbol": float(coefficients[1] / 4.0), + "fourth_power_phase_fit_rms_rad": float( + np.sqrt(np.sum(weights * residuals**2) / np.sum(weights)) + ), + "phase_track_block_count": int(np.sum(select)), + } + + +def hard_qpsk_ids(symbols: np.ndarray, rotation_quadrants: int, conjugate: bool) -> np.ndarray: + values = np.asarray(symbols, dtype=np.complex64) + if conjugate: + values = np.conj(values) + values = values * np.exp(-1j * rotation_quadrants * np.pi / 2.0) + distances = np.abs(values[:, None] - QPSK_STATES[None, :]) + return np.argmin(distances, axis=1).astype(np.uint8) + + +def constellation_separation_metrics( + symbols: np.ndarray, + symbol_ids: np.ndarray, +) -> dict: + """Описать четыре принятые группы без изменения или подгонки декодера.""" + + values = np.asarray(symbols, dtype=np.complex64) + ids = np.asarray(symbol_ids, dtype=np.uint8) + if values.ndim != 1 or ids.ndim != 1 or len(values) != len(ids) or len(values) == 0: + raise ValueError("Символы и решения должны быть непустыми одномерными массивами одной длины") + centroids: list[complex] = [] + counts: list[int] = [] + residual_power = 0.0 + for state in range(4): + group = values[ids == state] + counts.append(int(len(group))) + if len(group) == 0: + centroids.append(complex(float("nan"), float("nan"))) + continue + centroid = complex(np.mean(group)) + centroids.append(centroid) + residual_power += float(np.sum(np.abs(group - centroid) ** 2)) + finite_centroids = [value for value in centroids if np.isfinite(value)] + distances = [ + abs(finite_centroids[left] - finite_centroids[right]) + for left in range(len(finite_centroids)) + for right in range(left + 1, len(finite_centroids)) + ] + within_rms = math.sqrt(residual_power / len(values)) + minimum_distance = float(min(distances)) if len(distances) == 6 else float("nan") + fourth_power_coherence = float( + abs(np.mean(values.astype(np.complex128) ** 4)) + / max(float(np.mean(np.abs(values.astype(np.complex128)) ** 4)), 1e-30) + ) + return { + "state_counts": counts, + "minimum_state_fraction": float(min(counts) / len(values)), + "centroids": [ + {"i": float(value.real), "q": float(value.imag)} + for value in centroids + ], + "minimum_centroid_distance": minimum_distance, + "pooled_within_state_rms": float(within_rms), + "centroid_separation_to_within_rms": float( + minimum_distance / max(within_rms, 1e-30) + ), + "fourth_power_coherence": fourth_power_coherence, + "note": "Группы построены по жёстким решениям декодера; метрики сами по себе не доказывают правильную позицию последовательности", + } + + +def recover_counter_from_iq( + samples: np.ndarray, + sample_rate_hz: float, + nominal_samples_per_symbol: int = NOMINAL_SAMPLES_PER_SYMBOL, + fft_samples: int = 262_144, + minimum_fourth_power_hz: float = FOURTH_POWER_MINIMUM_HZ, + maximum_fourth_power_hz: float = FOURTH_POWER_MAXIMUM_HZ, +) -> dict: + """Восстановить символы и выбрать только достоверную ориентацию QPSK.""" + + cfo = estimate_qpsk_cfo_fourth_power( + samples, + sample_rate_hz, + fft_samples, + minimum_fourth_power_hz, + maximum_fourth_power_hz, + ) + frequency_corrected = correct_frequency(samples, sample_rate_hz, cfo["carrier_offset_hz"]) + timing = estimate_samples_per_symbol(frequency_corrected, nominal_samples_per_symbol) + symbols, centers = integrate_symbol_grid( + frequency_corrected, + timing["samples_per_symbol"], + timing["timing_intercept_sample"], + ) + phase_corrected, phase_metrics = correct_qpsk_phase_track(symbols) + candidates = [] + for conjugate in (False, True): + for rotation in range(4): + ids = hard_qpsk_ids(phase_corrected, rotation, conjugate) + analysis = analyze_counter_symbols(ids) + candidates.append( + ( + analysis["anchor_count"], + analysis["recovered_symbol_count"], + -len(analysis["events"]), + conjugate, + rotation, + ids, + analysis, + ) + ) + selected = max(candidates, key=lambda row: row[:3]) + separation = constellation_separation_metrics(phase_corrected, selected[5]) + return { + "symbol_ids": selected[5], + "symbol_center_samples": centers, + "demodulated_symbols": phase_corrected, + "counter_analysis": selected[6], + "carrier": cfo, + "timing": timing + | { + "sample_clock_error_ppm_relative_to_nominal_sps": float( + (timing["samples_per_symbol"] / nominal_samples_per_symbol - 1.0) * 1e6 + ) + }, + "phase": phase_metrics, + "orientation": { + "conjugated": bool(selected[3]), + "rotation_quadrants": int(selected[4]), + }, + "constellation_separation": separation, + } + + +def capture_quality_metrics( + samples: np.ndarray, + background_rms: float, + block_samples: int = SIGNAL_POWER_BLOCK_SAMPLES, +) -> dict: + """Измерить raw и его наиболее мощный заранее фиксированный интервал.""" + + values = np.asarray(samples, dtype=np.complex64) + if values.ndim != 1 or len(values) < block_samples or not np.all(np.isfinite(values)): + raise ValueError("Недействительный raw для оценки качества") + if not np.isfinite(background_rms) or background_rms <= 0.0: + raise ValueError("Фоновое RMS должно быть положительным") + powers = np.abs(values) ** 2 + block_count = len(values) // block_samples + block_powers = np.asarray( + [ + np.mean(powers[index * block_samples : (index + 1) * block_samples]) + for index in range(block_count) + ], + dtype=np.float64, + ) + background_power = float(background_rms**2) + component_peak = np.maximum(np.abs(values.real), np.abs(values.imag)) + return { + "sample_count": int(len(values)), + "dtype": str(values.dtype), + "mean_amplitude": float(np.mean(np.abs(values))), + "rms_amplitude": float(np.sqrt(np.mean(powers))), + "maximum_amplitude": float(np.max(np.abs(values))), + "maximum_component": float(np.max(component_peak)), + "clipped_component_fraction": float(np.mean(component_peak >= 127.0 / 128.0)), + "dc_mean_i": float(np.mean(values.real)), + "dc_mean_q": float(np.mean(values.imag)), + "background_rms": float(background_rms), + "background_power": background_power, + "fixed_power_block_samples": int(block_samples), + "fixed_power_block_count": int(block_count), + "maximum_block_power": float(np.max(block_powers)), + "maximum_block_power_above_background_db": float( + 10.0 * math.log10(max(float(np.max(block_powers)), 1e-300) / background_power) + ), + } + + +def configure_counter_receiver(receiver) -> dict: + """Задать единственную разрешённую рабочую точку HackRF 8/8.""" + + base = receiver.configure_rx() + gains = receiver.set_rx_gains(HACKRF_LNA_GAIN_DB, HACKRF_VGA_GAIN_DB) + parameters = base | gains + validate_counter_working_point(parameters) + return parameters + + +def validate_counter_working_point(parameters: dict) -> None: + """Запретить TX, если HackRF не настроен на единственную рабочую точку.""" + + expected = { + "set_center_frequency_hz": HACKRF_CENTER_HZ, + "set_sample_rate_hz": NOMINAL_SAMPLE_RATE, + "lna_gain_db": HACKRF_LNA_GAIN_DB, + "vga_gain_db": HACKRF_VGA_GAIN_DB, + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + } + mismatches = { + key: {"expected": value, "actual": parameters.get(key)} + for key, value in expected.items() + if parameters.get(key) != value + } + if mismatches: + raise RuntimeError(f"Рабочая точка HackRF не подтверждена: {mismatches}") + + +def preflight_counter_receiver(hackrf_module) -> dict: + """Проверить HackRF на 8/8 до конфигурирования и запуска Pluto TX.""" + + parameters = hackrf_module.preflight_hackrf( + HACKRF_LNA_GAIN_DB, + HACKRF_VGA_GAIN_DB, + ) + validate_counter_working_point(parameters) + return parameters + + +def preflight_counter_hardware(hackrf_module, pluto_module) -> dict: + """Проверить прямой USB Pluto+ и HackRF без вызова TX.""" + + pluto_uri = pluto_module.discover_pluto_usb_uri() + return { + "pluto": pluto_module.preflight_pluto(pluto_uri), + "hackrf": preflight_counter_receiver(hackrf_module), + } + + +def validate_pluto_working_point(parameters: dict) -> None: + """Запретить TX при отклонении фактических настроек Pluto+.""" + + exact_expected = { + "actual_center_frequency_hz": HACKRF_CENTER_HZ, + "actual_gain_db": -30.0, + } + mismatches = { + key: {"expected": value, "actual": parameters.get(key)} + for key, value in exact_expected.items() + if parameters.get(key) != value + } + actual_sample_rate_hz = parameters.get("actual_sample_rate_hz") + if not isinstance(actual_sample_rate_hz, (int, float)) or not math.isclose( + float(actual_sample_rate_hz), + NOMINAL_SAMPLE_RATE, + rel_tol=0.0, + abs_tol=PLUTO_SAMPLE_RATE_TOLERANCE_HZ, + ): + mismatches["actual_sample_rate_hz"] = { + "expected": NOMINAL_SAMPLE_RATE, + "absolute_tolerance_hz": PLUTO_SAMPLE_RATE_TOLERANCE_HZ, + "actual": actual_sample_rate_hz, + } + if mismatches: + raise RuntimeError(f"Рабочая точка Pluto+ не подтверждена: {mismatches}") + + +def synthesize_counter_iq( + start_position: int, + symbol_count: int, + samples_per_symbol: int, + sample_rate_hz: float, + carrier_offset_hz: float = 0.0, + sample_clock_error_ppm: float = 0.0, + noise_standard_deviation: float = 0.0, + seed: int = 43, +) -> np.ndarray: + """Синтетический RF-вход для проверки CFO, SRO и шума до аппаратуры.""" + + symbols = counter_qpsk_symbols(start_position, symbol_count) + transmitted = np.repeat(symbols, samples_per_symbol).astype(np.complex64) + scale = 1.0 + sample_clock_error_ppm * 1e-6 + output_count = int(round(len(transmitted) * scale)) + source = np.arange(output_count, dtype=np.float64) / scale + base = np.arange(len(transmitted), dtype=np.float64) + real = np.interp(source, base, transmitted.real) + imag = np.interp(source, base, transmitted.imag) + received = real + 1j * imag + indexes = np.arange(output_count, dtype=np.float64) + received *= np.exp(1j * 2.0 * np.pi * carrier_offset_hz * indexes / sample_rate_hz) + if noise_standard_deviation: + generator = np.random.default_rng(seed) + received += noise_standard_deviation * ( + generator.normal(size=output_count) + 1j * generator.normal(size=output_count) + ) + return received.astype(np.complex64) + + +def _event_hardware_rows( + events: list[dict], + symbol_centers: np.ndarray, + callback_boundaries, + raw_samples: np.ndarray, +) -> tuple[list[dict], dict]: + callback_samples = np.asarray( + [row.accepted_end_sample for row in callback_boundaries], dtype=np.int64 + ) + midpoints = ( + (callback_samples[:-1] + callback_samples[1:]) // 2 + if len(callback_samples) >= 2 + else np.empty(0, dtype=np.int64) + ) + categories = {"exactly_on_boundary": 0, "within_100": 0, "within_1000": 0, "farther": 0} + control_categories = dict.fromkeys(categories, 0) + rows = [] + for event in events: + symbol_index = int(event["received_symbol_index"]) + sample_index = int(round(symbol_centers[symbol_index])) + nearest_index = int(np.argmin(np.abs(callback_samples - sample_index))) + signed_distance = sample_index - int(callback_samples[nearest_index]) + absolute = abs(signed_distance) + category = ( + "exactly_on_boundary" + if absolute == 0 + else "within_100" + if absolute <= 100 + else "within_1000" + if absolute <= 1000 + else "farther" + ) + categories[category] += 1 + if len(midpoints): + midpoint_distance = int(np.min(np.abs(midpoints - sample_index))) + control_category = ( + "exactly_on_boundary" + if midpoint_distance == 0 + else "within_100" + if midpoint_distance <= 100 + else "within_1000" + if midpoint_distance <= 1000 + else "farther" + ) + control_categories[control_category] += 1 + else: + midpoint_distance = None + local_start = max(0, sample_index - 2_000) + local_end = min(len(raw_samples), sample_index + 2_000) + local = np.asarray(raw_samples[local_start:local_end]) + before = np.asarray(raw_samples[max(0, sample_index - 1_000) : sample_index]) + after = np.asarray(raw_samples[sample_index : min(len(raw_samples), sample_index + 1_000)]) + row = dict(event) + row.update( + { + "estimated_raw_sample_index": sample_index, + "nearest_callback_index": callback_boundaries[nearest_index].callback_index, + "signed_distance_to_callback_samples": signed_distance, + "distance_category": category, + "distance_to_nearest_midpoint_samples": midpoint_distance, + "local_mean_amplitude": float(np.mean(np.abs(local))), + "local_peak_amplitude": float(np.max(np.abs(local))), + "before_mean_amplitude": float(np.mean(np.abs(before))), + "after_mean_amplitude": float(np.mean(np.abs(after))), + "local_exact_zero_fraction": float(np.mean(local == 0)), + "exact_lost_or_repeated_iq_samples": None, + "exact_iq_sample_count_status": "not_computed", + "exact_iq_sample_count_reason": ( + "цифровая последовательность не доказывает число потерянных " + "или повторённых сырых I/Q-отсчётов" + ), + } + ) + rows.append(row) + return rows, { + "event_distance_distribution": categories, + "midpoint_control_distance_distribution": control_categories, + } + + +def analyze_hardware_counter( + raw_samples: np.ndarray, + callback_boundaries, + sample_rate_hz: float, +) -> tuple[dict, dict]: + recovery = recover_counter_from_iq(raw_samples, sample_rate_hz) + digital = recovery["counter_analysis"] + runs = digital["runs"] + useful_positions: set[int] = set() + transmitted_positions: set[int] = set() + for run in runs: + start = int(run["first_anchor"]) + stop = int(run["last_anchor"]) + VERIFY_WINDOW_SYMBOLS + positions = ( + np.arange(start, stop, dtype=np.int64) + int(run["offset"]) + ) % SYMBOL_PERIOD + transmitted_positions.update( + int(value) for value in positions if 0 <= value < TRANSMITTED_SYMBOL_COUNT + ) + useful_positions.update( + int(value) + for value in positions + if MARGIN_SYMBOL_COUNT + <= value + < MARGIN_SYMBOL_COUNT + USEFUL_SYMBOL_COUNT + ) + event_rows, boundary_relation = _event_hardware_rows( + digital["events"], + recovery["symbol_center_samples"], + callback_boundaries, + raw_samples, + ) + if digital["classification"] == "A" and len(useful_positions) >= int( + 0.95 * USEFUL_SYMBOL_COUNT + ): + classification = "A" + reason = "Полезный цифровой счётчик восстановлен без скачков позиции" + elif digital["classification"] == "B": + classification = "B" + reason = "Подтверждены скачки позиции цифрового счётчика" + else: + classification = "D" + reason = "Недостаточно достоверно восстановленных полезных символов" + analysis = { + "classification": classification, + "reason": reason, + "digital_sequence_continuity": ( + "ДА" if classification == "A" else "НЕТ" if classification == "B" else "НЕ ОПРЕДЕЛЕНО" + ), + "raw_iq_continuity": "НЕ ОПРЕДЕЛЕНО", + "raw_iq_reason": ( + "Цифровая последовательность доказывает порядок символов, но сама по себе не доказывает сохранность каждого I/Q-отсчёта" + ), + "transmitted_symbol_count": TRANSMITTED_SYMBOL_COUNT, + "useful_transmitted_symbol_count": USEFUL_SYMBOL_COUNT, + "recovered_transmitted_position_count": len(transmitted_positions), + "recovered_useful_position_count": len(useful_positions), + "ordinary_symbol_error_count": digital["ordinary_symbol_error_count"], + "ordinary_symbol_error_count_status": ( + "computed" + if digital["ordinary_symbol_error_count"] is not None + else "not_computed" + ), + "confirmed_position_jump_count": len(event_rows), + "skip_event_count": sum(row["type"] == "пропуск" for row in event_rows), + "repeat_event_count": sum(row["type"] == "повтор" for row in event_rows), + "insertion_event_count": sum(row["type"] == "вставка" for row in event_rows), + "ambiguous_event_count": sum(row["type"] == "неоднозначно" for row in event_rows), + "carrier": recovery["carrier"], + "timing": recovery["timing"], + "phase": recovery["phase"], + "orientation": recovery["orientation"], + "constellation_separation": recovery["constellation_separation"], + "anchor_count": digital["anchor_count"], + "stable_run_count": digital["stable_run_count"], + "recovered_symbol_count": digital["recovered_symbol_count"], + "events": event_rows, + "runs": runs, + "callback_relation": boundary_relation, + } + private = { + "symbol_ids": recovery["symbol_ids"], + "symbol_center_samples": recovery["symbol_center_samples"], + "demodulated_symbols": recovery["demodulated_symbols"], + } + return analysis, private + + +def _write_csv(path: Path, rows: list[dict], fieldnames: list[str]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def save_counter_artifacts( + analysis: dict, + private: dict, + output_directory: Path = PROCESSED_DIRECTORY, +) -> tuple[Path, ...]: + from matplotlib import pyplot as plt + + output_directory.mkdir(parents=True, exist_ok=True) + summary = { + key: value + for key, value in analysis.items() + if key not in {"events", "runs", "carrier", "timing", "phase", "orientation", "constellation_separation", "callback_relation"} + } + summary.update( + { + "carrier_offset_hz": analysis["carrier"]["carrier_offset_hz"], + "carrier_peak_above_noise_db": analysis["carrier"]["peak_above_noise_db"], + "samples_per_symbol": analysis["timing"]["samples_per_symbol"], + "sample_clock_error_ppm": analysis["timing"][ + "sample_clock_error_ppm_relative_to_nominal_sps" + ], + } + ) + _write_csv(output_directory / "summary.csv", [summary], list(summary)) + event_fields = [ + "received_symbol_index", + "expected_state_number", + "recognized_state_number", + "expected_next_position", + "actual_next_position", + "position_delta_symbols", + "minimum_confirmed_gap_symbols", + "type", + "estimated_raw_sample_index", + "nearest_callback_index", + "signed_distance_to_callback_samples", + "distance_category", + "distance_to_nearest_midpoint_samples", + "local_mean_amplitude", + "local_peak_amplitude", + "before_mean_amplitude", + "after_mean_amplitude", + "local_exact_zero_fraction", + "exact_lost_or_repeated_iq_samples", + "exact_iq_sample_count_status", + "exact_iq_sample_count_reason", + ] + _write_csv(output_directory / "discontinuities.csv", analysis["events"], event_fields) + + figure, axis = plt.subplots(figsize=(10, 4.5)) + for run in analysis["runs"]: + axis.plot( + [run["first_anchor"], run["last_anchor"]], + [run["offset"], run["offset"]], + linewidth=3, + ) + if not analysis["runs"]: + axis.text( + 0.5, + 0.5, + "Достоверные участки позиции не найдены", + ha="center", + va="center", + transform=axis.transAxes, + ) + axis.set_title("Устойчивые участки карты позиции цифрового счётчика") + axis.set_xlabel("Индекс принятого символа") + axis.set_ylabel("Смещение позиции modulo периода") + axis.grid(True, alpha=0.3) + figure.tight_layout() + figure.savefig(output_directory / "position_map.png", dpi=150) + plt.close(figure) + + constellation = np.asarray(private["demodulated_symbols"]) + if len(constellation) > 20_000: + constellation = constellation[:: max(1, len(constellation) // 20_000)] + figure, axis = plt.subplots(figsize=(6, 6)) + axis.scatter(constellation.real, constellation.imag, s=2, alpha=0.25) + axis.set_title( + "Мягкие символы: четыре состояния не выделены" + if analysis["classification"] == "D" + else "Восстановленные четыре комплексных состояния" + ) + axis.set_xlabel("Синфазная составляющая I") + axis.set_ylabel("Квадратурная составляющая Q") + axis.set_aspect("equal", adjustable="box") + axis.grid(True, alpha=0.3) + figure.tight_layout() + figure.savefig(output_directory / "constellation.png", dpi=150) + plt.close(figure) + + lines = [ + "Lab043: цифровой счётчик непрерывности HackRF", + "", + f"Классификация: {analysis['classification']}.", + f"Причина: {analysis['reason']}.", + f"Передано символов: {analysis['transmitted_symbol_count']}, полезных: {analysis['useful_transmitted_symbol_count']}.", + f"Восстановлено полезных позиций: {analysis['recovered_useful_position_count']}.", + f"Обычных ошибок символов: {analysis['ordinary_symbol_error_count']}.", + f"Подтверждённых скачков позиции: {analysis['confirmed_position_jump_count']}.", + f"Пропусков: {analysis['skip_event_count']}; повторов: {analysis['repeat_event_count']}; вставок: {analysis['insertion_event_count']}.", + f"Непрерывность цифровой последовательности: {analysis['digital_sequence_continuity']}.", + f"Непрерывность сырых I/Q-отсчётов: {analysis['raw_iq_continuity']}.", + "", + "I/Q означает синфазные и квадратурные комплексные отсчёты.", + ] + (output_directory / "report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + return tuple(sorted(output_directory.iterdir())) + + +def run_one_hardware_counter( + raw_directory: Path = RAW_DIRECTORY, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Выполнить ровно один разрешённый аппаратный опыт цифрового счётчика.""" + + from experiments import lab043_hackrf_hardware as hackrf + from experiments import lab043_pluto_to_rtlsdr as lab043 + from experiments import lab043_prbs11_hardware as rtl_hardware + + pluto_uri = rtl_hardware.discover_pluto_usb_uri() + pluto_preflight = rtl_hardware.preflight_pluto(pluto_uri) + hackrf_preflight = preflight_counter_receiver(hackrf) + pluto, tx_parameters = rtl_hardware.configure_pluto(pluto_uri) + validate_pluto_working_point(tx_parameters) + actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) + waveform, waveform_parameters = build_counter_waveform(actual_tx_rate) + waveform_sha256 = hashlib.sha256(waveform.tobytes()).hexdigest() + device_waveform = lab043.to_pluto_tx_samples(waveform) + target_samples = math.ceil( + ( + RX_LEADING_SECONDS + + waveform_parameters["total_duration_seconds"] + + RX_TRAILING_SECONDS + + FIXED_TX_UPLOAD_MARGIN_SECONDS + ) + * NOMINAL_SAMPLE_RATE + ) + hackrf.HACKRF_EXPECTED_CALLBACK_BYTES // 2 + capture_started = datetime.now(timezone.utc) + capture_holder = [] + errors: list[str] = [] + tx_timing: dict[str, float] = {} + + with hackrf.HackrfRxDevice() as receiver: + rx_parameters = configure_counter_receiver(receiver) + + def receive() -> None: + try: + capture_holder.append(receiver.capture(target_samples)) + except Exception as error: # pragma: no cover - аппаратный путь + errors.append(f"RX {type(error).__name__}: {error}") + + rx_thread = threading.Thread(target=receive, daemon=True) + rx_thread.start() + deadline = time.perf_counter() + 5.0 + while time.perf_counter() < deadline: + if receiver.library.hackrf_is_streaming(receiver.device) == 1: + break + if errors: + break + time.sleep(0.01) + else: + raise RuntimeError("HackRF RX не подтвердил поток; Pluto TX не включён") + time.sleep(RX_LEADING_SECONDS) + tx_timing["tx_call_started_monotonic"] = time.perf_counter() + tx_timing["tx_buffer_hold_seconds"] = rtl_hardware.transmit_noncyclic_buffer_once( + pluto, + device_waveform, + actual_tx_rate, + ) + tx_timing["tx_completed_monotonic"] = time.perf_counter() + rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 10.0) + if rx_thread.is_alive(): + raise RuntimeError("Единственный непрерывный HackRF RX-сеанс не завершился") + if errors: + raise RuntimeError("; ".join(errors)) + if len(capture_holder) != 1: + raise RuntimeError("HackRF не вернул единственный массив") + capture = capture_holder[0] + if len(capture.samples) != target_samples or not np.all(np.isfinite(capture.samples)): + raise RuntimeError("Размер или содержимое массива RX недействительны") + boundary_rows = [row.__dict__ for row in capture.boundaries] + overload = { + "peak_magnitude": float(np.max(np.abs(capture.samples))), + "clipped_component_fraction": float( + np.mean( + (np.abs(capture.samples.real * 128.0) >= 127) + | (np.abs(capture.samples.imag * 128.0) >= 127) + ) + ), + } + metadata = { + "experiment": "Lab043 HackRF digital continuity counter", + "capture_status": "captured", + "processing_status": "pending", + "processing_error": None, + "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters | { + "serial_number": hackrf_preflight["serial_number"], + "firmware_version": hackrf_preflight["firmware_version"], + "libhackrf_version": hackrf_preflight["libhackrf_version"], + }, + "pluto_preflight": pluto_preflight, + "hackrf_preflight": hackrf_preflight, + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF ANTENNA; HackRF RX only", + "waveform_sha256": waveform_sha256, + "waveform": { + "purpose": "цифровой счётчик времени, не измерение BER", + "alphabet": "four states (+/-1 +/- j)/sqrt(2)", + "lfsr_polynomial": LFSR_POLYNOMIAL, + "lfsr_degree": LFSR_DEGREE, + "lfsr_initial_state_hex": f"0x{LFSR_INITIAL_STATE:05X}", + "bit_period": LFSR_PERIOD_BITS, + "symbol_period": SYMBOL_PERIOD, + "minimum_position_symbols": MINIMUM_POSITION_SYMBOLS, + "verification_window_symbols": VERIFY_WINDOW_SYMBOLS, + **waveform_parameters, + }, + "tx_waveform_duration_seconds": waveform_parameters["total_duration_seconds"], + "tx_timing": tx_timing, + "callback_count": capture.diagnostics["callback_count_total"], + "callback_boundaries": boundary_rows, + "continuous_rx": capture.diagnostics | {"boundaries": boundary_rows}, + "expected_intervals": { + "rx_leading_seconds": RX_LEADING_SECONDS, + "rx_trailing_seconds": RX_TRAILING_SECONDS, + "fixed_tx_upload_margin_seconds": FIXED_TX_UPLOAD_MARGIN_SECONDS, + "useful_symbol_positions": [ + MARGIN_SYMBOL_COUNT, + MARGIN_SYMBOL_COUNT + USEFUL_SYMBOL_COUNT, + ], + }, + "overload": overload, + } + stamp = capture_started.strftime("%Y%m%d_%H%M%S") + base = raw_directory / f"lab043_hackrf_counter_{stamp}" + iq_path, metadata_path, document = lab043.save_raw_iq_before_processing( + base, + capture.samples, + metadata, + ) + callback_boundaries = capture.boundaries + continuous_rx = dict(capture.diagnostics) + del capture_holder, capture, device_waveform, waveform + gc.collect() + restored = np.load(iq_path, allow_pickle=False) + if hashlib.sha256(iq_path.read_bytes()).hexdigest() != document["iq_sha256"]: + raise RuntimeError("SHA-256 raw изменился до обработки") + try: + analysis, private = analyze_hardware_counter( + restored, + callback_boundaries, + NOMINAL_SAMPLE_RATE, + ) + save_counter_artifacts(analysis, private, processed_directory) + except Exception as error: + message = f"{type(error).__name__}: {error}" + lab043.update_processing_metadata( + metadata_path, + processing_status="processing_failed", + processing_error=message, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": document["iq_sha256"], + "processing_status": "processing_failed", + "processing_error": message, + "continuous_rx": continuous_rx, + } + lab043.update_processing_metadata( + metadata_path, + processing_status="success", + processing_error=None, + analysis=analysis, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": document["iq_sha256"], + "processing_status": "success", + "tx_parameters": tx_parameters, + "rx_parameters": metadata["rx_parameters"], + "waveform": metadata["waveform"], + "overload": overload, + "continuous_rx": continuous_rx, + "analysis": analysis, + } + + +def reprocess_saved_counter( + metadata_path: Path, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Повторно обработать сохранённый raw без открытия SDR и без TX.""" + + from experiments import lab043_hackrf_hardware as hackrf + from experiments import lab043_pluto_to_rtlsdr as lab043 + + metadata_path = Path(metadata_path) + document = json.loads(metadata_path.read_text(encoding="utf-8")) + iq_path = metadata_path.parent / document["iq_file"] + actual_sha = hashlib.sha256(iq_path.read_bytes()).hexdigest() + if not document.get("raw_verified") or actual_sha != document.get("iq_sha256"): + raise RuntimeError("Сохранённый raw не прошёл SHA-256 перед повторной обработкой") + samples = np.load(iq_path, allow_pickle=False) + if samples.dtype != np.dtype(np.complex64) or len(samples) != document["sample_count"]: + raise RuntimeError("Формат raw не совпал с первичным JSON") + boundaries = tuple( + hackrf.HackrfBoundary(**row) for row in document["callback_boundaries"] + ) + try: + analysis, private = analyze_hardware_counter( + samples, + boundaries, + NOMINAL_SAMPLE_RATE, + ) + save_counter_artifacts(analysis, private, processed_directory) + except Exception as error: + message = f"{type(error).__name__}: {error}" + lab043.update_processing_metadata( + metadata_path, + processing_status="processing_failed", + processing_error=message, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": actual_sha, + "processing_status": "processing_failed", + "processing_error": message, + } + lab043.update_processing_metadata( + metadata_path, + processing_status="success", + processing_error=None, + analysis=analysis, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": actual_sha, + "processing_status": "success", + "analysis": analysis, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--preflight-only", action="store_true") + group.add_argument("--run-one-counter", action="store_true") + group.add_argument("--reprocess-metadata", type=Path) + arguments = parser.parse_args() + if arguments.preflight_only: + from experiments import lab043_hackrf_hardware as hackrf + from experiments import lab043_prbs11_hardware as pluto_hardware + + result = preflight_counter_hardware(hackrf, pluto_hardware) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + result = ( + reprocess_saved_counter(arguments.reprocess_metadata) + if arguments.reprocess_metadata is not None + else run_one_hardware_counter() + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["processing_status"] == "success" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_hackrf_gain_ladder.py b/experiments/lab043_hackrf_gain_ladder.py new file mode 100644 index 0000000..5354853 --- /dev/null +++ b/experiments/lab043_hackrf_gain_ladder.py @@ -0,0 +1,402 @@ +"""Безопасный однотоновый поиск рабочего RX-усиления HackRF для Lab043. + +Разрешён один непрерывный циклический тон Pluto+ и заранее фиксированная +лестница усилений HackRF. RF amp и antenna bias всегда выключены. Здесь нет +BPSK, PRBS, пакетов, счётчика или JPEG. +""" + +from __future__ import annotations + +import argparse +import json +import math +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + +from experiments import lab043_hackrf_continuity as continuity +from experiments import lab043_hackrf_hardware as hackrf +from experiments import lab043_pluto_to_rtlsdr as lab043 +from experiments import lab043_prbs11_hardware as pluto_hardware + + +GAIN_LADDER = ((0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24)) +SETTLE_SECONDS = 0.25 +MEASUREMENT_SECONDS = 0.75 +FIRST_CAPTURE_SECONDS = SETTLE_SECONDS + MEASUREMENT_SECONDS +FFT_SAMPLES = 262_144 +SEARCH_LOW_HZ = 35_000.0 +SEARCH_HIGH_HZ = 65_000.0 +NOISE_EXCLUSION_HZ = 2_000.0 +DESIRABLE_TONE_OVER_NOISE_DB = 35.0 +NEAR_LIMIT_COMPONENT = 0.90 +PREFERRED_MAX_COMPONENT = 0.75 +CLIPPED_COMPONENT = 127.0 / 128.0 +NEAR_LIMIT_FRACTION_STOP = 1e-4 +NONLINEAR_HARMONIC_LIMIT_DBC = -30.0 +HARMONIC_DETECTION_MARGIN_DB = 10.0 +HARMONIC_SEARCH_HALF_WIDTH_HZ = 2_000.0 +HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ = 10_000.0 +OUTPUT_DIRECTORY = Path("data/processed/lab043_hackrf_gain_ladder") + + +def _db(power: float) -> float: + return 10.0 * math.log10(max(float(power), 1e-300)) + + +def alias_frequency_to_nyquist(frequency_hz: float, sample_rate_hz: float) -> float: + """Свернуть частоту в полуоткрытую полосу [-Fs/2, Fs/2).""" + + if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0: + raise ValueError("Частота дискретизации должна быть положительной") + return float( + (float(frequency_hz) + sample_rate_hz / 2.0) % sample_rate_hz + - sample_rate_hz / 2.0 + ) + + +def _cyclic_frequency_distance( + frequencies_hz: np.ndarray, + target_hz: float, + sample_rate_hz: float, +) -> np.ndarray: + return np.abs( + (frequencies_hz - target_hz + sample_rate_hz / 2.0) % sample_rate_hz + - sample_rate_hz / 2.0 + ) + + +def analyze_harmonic( + powers: np.ndarray, + frequencies: np.ndarray, + fundamental_frequency_hz: float, + fundamental_power: float, + order: int, + sample_rate_hz: float, +) -> dict: + """Оценить гармонику отдельно относительно тона и локального фона.""" + + if order not in (2, 3): + raise ValueError("Поддерживаются только вторая и третья гармоники") + expected_hz = alias_frequency_to_nyquist( + order * fundamental_frequency_hz, + sample_rate_hz, + ) + distances = _cyclic_frequency_distance(frequencies, expected_hz, sample_rate_hz) + peak_mask = distances <= HARMONIC_SEARCH_HALF_WIDTH_HZ + noise_mask = ( + (distances > HARMONIC_SEARCH_HALF_WIDTH_HZ) + & (distances <= HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ) + ) + if not np.any(peak_mask) or not np.any(noise_mask): + raise ValueError("Недостаточно спектральных отсчётов около гармоники") + local_indexes = np.flatnonzero(peak_mask) + peak_index = int(local_indexes[np.argmax(powers[local_indexes])]) + peak_power = float(powers[peak_index]) + local_noise_power = float(np.median(powers[noise_mask])) + above_local_noise_db = _db(peak_power) - _db(local_noise_power) + relative_to_fundamental_db = _db(peak_power) - _db(fundamental_power) + credible = bool(above_local_noise_db >= HARMONIC_DETECTION_MARGIN_DB) + nonlinear = bool( + credible and relative_to_fundamental_db >= NONLINEAR_HARMONIC_LIMIT_DBC + ) + return { + "order": order, + "expected_frequency_hz": expected_hz, + "peak_frequency_hz": float(frequencies[peak_index]), + "peak_power_dbfs": _db(peak_power), + "local_noise_floor_dbfs_per_fft_bin": _db(local_noise_power), + "above_local_noise_db": float(above_local_noise_db), + "relative_to_fundamental_db": float(relative_to_fundamental_db), + "credible": credible, + "nonlinear": nonlinear, + } + + +def measure_tone_segment( + samples: np.ndarray, + sample_rate_hz: float, + fft_samples: int = FFT_SAMPLES, +) -> dict: + """Измерить тон и перегрузку по неизменяемой спектральной методике.""" + + received = np.asarray(samples, dtype=np.complex64) + if received.ndim != 1 or len(received) < fft_samples: + raise ValueError("Участок короче одного фиксированного БПФ-окна") + if not np.all(np.isfinite(received)): + raise ValueError("Участок содержит нечисловые отсчёты") + + segment_count = len(received) // fft_samples + window = np.hanning(fft_samples).astype(np.float64) + coherent_scale = float(np.sum(window)) + powers = np.zeros(fft_samples, dtype=np.float64) + for index in range(segment_count): + segment = received[index * fft_samples : (index + 1) * fft_samples] + spectrum = np.fft.fft(segment * window) + powers += np.abs(spectrum / coherent_scale) ** 2 + powers /= segment_count + frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate_hz) + + search = (frequencies >= SEARCH_LOW_HZ) & (frequencies <= SEARCH_HIGH_HZ) + candidates = np.flatnonzero(search) + peak_index = int(candidates[np.argmax(powers[candidates])]) + peak_bin_hz = float(frequencies[peak_index]) + resolution_hz = float(sample_rate_hz / fft_samples) + left = (peak_index - 1) % fft_samples + right = (peak_index + 1) % fft_samples + y0, y1, y2 = np.log(np.maximum(powers[[left, peak_index, right]], 1e-300)) + denominator = y0 - 2.0 * y1 + y2 + bin_offset = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator + tone_frequency_hz = peak_bin_hz + float(np.clip(bin_offset, -0.5, 0.5)) * resolution_hz + + noise_mask = search & (np.abs(frequencies - tone_frequency_hz) >= NOISE_EXCLUSION_HZ) + noise_power = float(np.median(powers[noise_mask])) + tone_power = float(powers[peak_index]) + tone_over_noise_db = _db(tone_power) - _db(noise_power) + + second = analyze_harmonic( + powers, + frequencies, + tone_frequency_hz, + tone_power, + 2, + sample_rate_hz, + ) + third = analyze_harmonic( + powers, + frequencies, + tone_frequency_hz, + tone_power, + 3, + sample_rate_hz, + ) + + component_peak = np.maximum(np.abs(received.real), np.abs(received.imag)) + near_fraction = float(np.mean(component_peak >= NEAR_LIMIT_COMPONENT)) + clipped_fraction = float(np.mean(component_peak >= CLIPPED_COMPONENT)) + max_component = float(np.max(component_peak)) + harmonic_nonlinearity = bool(second["nonlinear"] or third["nonlinear"]) + stop = bool( + clipped_fraction > 0.0 + or near_fraction >= NEAR_LIMIT_FRACTION_STOP + or max_component >= NEAR_LIMIT_COMPONENT + or harmonic_nonlinearity + ) + return { + "sample_count": int(len(received)), + "fft_samples": int(fft_samples), + "fft_segment_count": int(segment_count), + "fft_resolution_hz": resolution_hz, + "tone_frequency_hz": float(tone_frequency_hz), + "tone_peak_bin_hz": peak_bin_hz, + "tone_power_dbfs": _db(tone_power), + "noise_floor_dbfs_per_fft_bin": _db(noise_power), + "tone_over_noise_db": float(tone_over_noise_db), + "mean_amplitude": float(np.mean(np.abs(received))), + "rms_amplitude": float(np.sqrt(np.mean(np.abs(received) ** 2))), + "maximum_amplitude": float(np.max(np.abs(received))), + "maximum_component": max_component, + "near_limit_component_fraction": near_fraction, + "clipped_component_fraction": clipped_fraction, + "dc_mean_i": float(np.mean(received.real)), + "dc_mean_q": float(np.mean(received.imag)), + "second_harmonic": second, + "third_harmonic": third, + "second_harmonic_dbc": second["relative_to_fundamental_db"], + "third_harmonic_dbc": third["relative_to_fundamental_db"], + "harmonic_nonlinearity_detected": harmonic_nonlinearity, + "stop_gain_escalation": stop, + "stop_reason": ( + "перегрузка/близость к пределу/гармоническая нелинейность" + if stop + else "" + ), + } + + +def choose_preferred_gain(rows: list[dict]) -> dict | None: + """Выбрать минимальное безопасное усиление без погони за лишними децибелами.""" + + safe = [ + row + for row in rows + if not row["metrics"]["stop_gain_escalation"] + and row["metrics"]["maximum_component"] <= PREFERRED_MAX_COMPONENT + ] + if not safe: + return None + desirable = [ + row + for row in safe + if row["metrics"]["tone_over_noise_db"] >= DESIRABLE_TONE_OVER_NOISE_DB + ] + if desirable: + candidates = desirable + else: + best_margin = max(row["metrics"]["tone_over_noise_db"] for row in safe) + candidates = [ + row + for row in safe + if row["metrics"]["tone_over_noise_db"] >= best_margin - 2.0 + ] + return min( + candidates, + key=lambda row: (row["lna_gain_db"] + row["vga_gain_db"], row["step_index"]), + ) + + +def _capture(receiver: hackrf.HackrfRxDevice, seconds: float) -> hackrf.HackrfCapture: + count = math.ceil(seconds * hackrf.HACKRF_SAMPLE_RATE_HZ) + return receiver.capture(count) + + +def run_gain_ladder(output_directory: Path = OUTPUT_DIRECTORY) -> dict: + """Выполнить один аппаратный сеанс с одним непрерывным тоном.""" + + for lna, vga in GAIN_LADDER: + hackrf.validate_hackrf_rx_gains(lna, vga) + pluto_preflight = pluto_hardware.preflight_pluto() + hackrf_preflight = hackrf.preflight_hackrf() + pluto, tx_parameters = pluto_hardware.configure_pluto() + actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) + waveform, actual_tone_hz = continuity.build_periodic_tone(actual_tx_rate) + device_waveform = lab043.to_pluto_tx_samples(waveform) + rows: list[dict] = [] + tx_started = False + stop_reason = "лестница завершена" + started = datetime.now(timezone.utc) + + try: + with hackrf.HackrfRxDevice() as receiver: + rx_parameters = receiver.configure_rx() + first_holder: list[hackrf.HackrfCapture] = [] + first_errors: list[str] = [] + + def first_receive() -> None: + try: + first_holder.append(_capture(receiver, FIRST_CAPTURE_SECONDS)) + except Exception as error: # pragma: no cover - аппаратный путь + first_errors.append(f"{type(error).__name__}: {error}") + + first_gain = receiver.set_rx_gains(*GAIN_LADDER[0]) + rx_thread = threading.Thread(target=first_receive, daemon=True) + rx_thread.start() + deadline = time.perf_counter() + 5.0 + while time.perf_counter() < deadline: + if receiver.library.hackrf_is_streaming(receiver.device) == 1: + break + if first_errors: + break + time.sleep(0.01) + else: + raise RuntimeError("HackRF RX не подтвердил поток; TX не включён") + time.sleep(SETTLE_SECONDS) + pluto.tx_cyclic_buffer = True + pluto.tx_destroy_buffer() + pluto.tx(device_waveform) + tx_started = True + rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 5.0) + if rx_thread.is_alive(): + raise RuntimeError("Первый короткий RX-сеанс не завершился") + if first_errors: + raise RuntimeError("; ".join(first_errors)) + if len(first_holder) != 1: + raise RuntimeError("Первый RX-сеанс не вернул единственный массив") + + first_capture = first_holder[0] + measurement_count = math.floor(MEASUREMENT_SECONDS * hackrf.HACKRF_SAMPLE_RATE_HZ) + metrics = measure_tone_segment(first_capture.samples[-measurement_count:], hackrf.HACKRF_SAMPLE_RATE_HZ) + rows.append({ + "step_index": 0, + "lna_gain_db": GAIN_LADDER[0][0], + "vga_gain_db": GAIN_LADDER[0][1], + "gain_configuration": first_gain, + "capture_diagnostics": first_capture.diagnostics, + "metrics": metrics, + }) + + if metrics["stop_gain_escalation"]: + stop_reason = metrics["stop_reason"] + else: + for step_index, (lna_gain, vga_gain) in enumerate(GAIN_LADDER[1:], start=1): + gain_configuration = receiver.set_rx_gains(lna_gain, vga_gain) + time.sleep(SETTLE_SECONDS) + capture = _capture(receiver, MEASUREMENT_SECONDS) + metrics = measure_tone_segment(capture.samples, hackrf.HACKRF_SAMPLE_RATE_HZ) + rows.append({ + "step_index": step_index, + "lna_gain_db": lna_gain, + "vga_gain_db": vga_gain, + "gain_configuration": gain_configuration, + "capture_diagnostics": capture.diagnostics, + "metrics": metrics, + }) + if metrics["stop_gain_escalation"]: + stop_reason = metrics["stop_reason"] + break + finally: + if tx_started: + pluto.tx_destroy_buffer() + + preferred = choose_preferred_gain(rows) + preferred_summary = None if preferred is None else { + "step_index": preferred["step_index"], + "lna_gain_db": preferred["lna_gain_db"], + "vga_gain_db": preferred["vga_gain_db"], + "tone_over_noise_db": preferred["metrics"]["tone_over_noise_db"], + "desirable_margin_reached": bool( + preferred["metrics"]["tone_over_noise_db"] >= DESIRABLE_TONE_OVER_NOISE_DB + ), + } + result = { + "experiment": "Lab043 HackRF RX gain ladder with one continuous tone", + "started_utc": started.isoformat().replace("+00:00", "Z"), + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF RX; antennas removed", + "method": "one continuous cyclic Pluto tone; separate short HackRF RX sessions per gain point", + "fixed_gain_ladder": [list(point) for point in GAIN_LADDER], + "fixed_thresholds": { + "desirable_tone_over_noise_db": DESIRABLE_TONE_OVER_NOISE_DB, + "near_limit_component": NEAR_LIMIT_COMPONENT, + "preferred_max_component": PREFERRED_MAX_COMPONENT, + "clipped_component": CLIPPED_COMPONENT, + "near_limit_fraction_stop": NEAR_LIMIT_FRACTION_STOP, + "nonlinear_harmonic_limit_dbc": NONLINEAR_HARMONIC_LIMIT_DBC, + "harmonic_detection_margin_db": HARMONIC_DETECTION_MARGIN_DB, + "harmonic_search_half_width_hz": HARMONIC_SEARCH_HALF_WIDTH_HZ, + "harmonic_noise_outer_half_width_hz": HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ, + }, + "pluto_preflight": pluto_preflight, + "hackrf_preflight": hackrf_preflight, + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters, + "actual_transmitted_tone_offset_hz": actual_tone_hz, + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "completed_step_count": len(rows), + "stop_reason": stop_reason, + "rows": rows, + "preferred_gain": preferred_summary, + "digital_experiment_authorized": False, + } + output_directory.mkdir(parents=True, exist_ok=True) + stamp = started.strftime("%Y%m%d_%H%M%S") + output_path = output_directory / f"lab043_hackrf_gain_ladder_{stamp}.json" + output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + result["result_path"] = str(output_path) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY) + arguments = parser.parse_args() + result = run_gain_ladder(arguments.output_directory) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_hackrf_hardware.py b/experiments/lab043_hackrf_hardware.py new file mode 100644 index 0000000..d23d489 --- /dev/null +++ b/experiments/lab043_hackrf_hardware.py @@ -0,0 +1,1173 @@ +"""Один сравнительный короткий S Lab043: Pluto+ TX -> HackRF RX. + +HackRF используется только как приёмник через непрерывный callback-сеанс +libhackrf. Модуль не импортирует и не вызывает ни одну функцию HackRF TX. +""" + +from __future__ import annotations + +import argparse +import csv +import ctypes +import hashlib +import json +import math +import os +import subprocess +import sys +import threading +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from experiments import lab043_bpsk_asymmetry_diagnostics as diagnostics +from experiments import lab043_pluto_to_rtlsdr as lab043 +from experiments import lab043_prbs11_hardware as rtl_hardware +from protocol import bpsk_radio as radio + + +HACKRF_DLL = Path(r"C:\Users\user\radioconda\Library\bin\hackrf-0.dll") +HACKRF_INFO = Path(r"C:\Users\user\radioconda\Library\bin\hackrf_info.exe") +EXPECTED_SERIAL = "000000000000000097c063dc323b97d3" +HACKRF_CENTER_HZ = 435_000_000 +HACKRF_SAMPLE_RATE_HZ = 2_400_000.0 +HACKRF_RF_AMP = 0 +HACKRF_ANTENNA_BIAS = 0 +HACKRF_LNA_GAIN_DB = 8 +HACKRF_VGA_GAIN_DB = 8 +HACKRF_WARMUP_CALLBACK_COUNT = 1 +HACKRF_EXPECTED_CALLBACK_BYTES = 262_144 +HACKRF_PROBE_SAMPLE_COUNT = 65_536 +HACKRF_STREAM_TIMEOUT_SECONDS = 15.0 +RAW_DIRECTORY = Path("data/raw/lab043_hackrf") +PROCESSED_DIRECTORY = Path("data/processed/lab043_hackrf_compare") + + +def validate_hackrf_rx_gains(lna_gain_db: int, vga_gain_db: int) -> tuple[int, int]: + """Проверить официальные аппаратные ступени HackRF One RX.""" + + lna = int(lna_gain_db) + vga = int(vga_gain_db) + if lna != lna_gain_db or not 0 <= lna <= 40 or lna % 8: + raise ValueError("LNA должна быть 0..40 дБ с шагом 8 дБ") + if vga != vga_gain_db or not 0 <= vga <= 62 or vga % 2: + raise ValueError("VGA должна быть 0..62 дБ с шагом 2 дБ") + return lna, vga + + +class HackrfTransfer(ctypes.Structure): + _fields_ = ( + ("device", ctypes.c_void_p), + ("buffer", ctypes.POINTER(ctypes.c_uint8)), + ("buffer_length", ctypes.c_int), + ("valid_length", ctypes.c_int), + ("rx_ctx", ctypes.c_void_p), + ("tx_ctx", ctypes.c_void_p), + ) + + +HackrfCallback = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(HackrfTransfer)) + + +@dataclass(frozen=True) +class HackrfBoundary: + callback_index: int + valid_bytes: int + accepted_bytes: int + accepted_start_sample: int + accepted_end_sample: int + monotonic_seconds: float + block_sha256: str + buffer_length: int | None = None + + +@dataclass(frozen=True) +class HackrfCapture: + samples: np.ndarray + boundaries: tuple[HackrfBoundary, ...] + diagnostics: dict + raw_bytes: bytes = b"" + + +def signed_iq_bytes_to_complex64(raw: bytes | bytearray | memoryview) -> np.ndarray: + """Преобразовать interleaved signed int8 I/Q HackRF в complex64 [-1, 1).""" + + values = np.frombuffer(raw, dtype=np.int8) + if len(values) == 0 or len(values) % 2: + raise ValueError("Поток HackRF должен содержать чётное ненулевое число I/Q-байтов") + pairs = values.reshape(-1, 2).astype(np.float32) + return ((pairs[:, 0] + 1j * pairs[:, 1]) / 128.0).astype(np.complex64) + + +def copy_valid_callback_bytes( + buffer_pointer, + buffer_length: int, + valid_length: int, +) -> bytes: + """Скопировать только валидную interleaved-I/Q часть callback HackRF.""" + + if buffer_length <= 0: + raise ValueError("buffer_length callback должен быть положительным") + if valid_length <= 0 or valid_length % 2: + raise ValueError("valid_length callback должен быть положительным и чётным") + if valid_length > buffer_length: + raise ValueError("valid_length callback превышает buffer_length") + if not buffer_pointer: + raise ValueError("buffer callback не может быть нулевым") + return ctypes.string_at(buffer_pointer, valid_length) + + +def assemble_callback_blocks( + blocks: list[bytes], + target_sample_count: int, + warmup_callback_count: int = HACKRF_WARMUP_CALLBACK_COUNT, + buffer_lengths: list[int] | None = None, +) -> tuple[np.ndarray, tuple[HackrfBoundary, ...], dict]: + """Детерминированно собрать callback-блоки без перестановки и дубликатов очереди.""" + + if target_sample_count <= 0: + raise ValueError("Нужно положительное число комплексных отсчётов") + if warmup_callback_count < 0: + raise ValueError("Число разогревочных callback не может быть отрицательным") + if buffer_lengths is not None and len(buffer_lengths) != len(blocks): + raise ValueError("Число buffer_length не совпадает с числом callback-блоков") + accepted_parts: list[bytes] = [] + boundaries: list[HackrfBoundary] = [] + accepted_samples = 0 + received_bytes = 0 + for callback_index, block in enumerate(blocks): + payload = bytes(block) + if len(payload) % 2: + raise ValueError(f"Callback {callback_index} содержит нечётное число байтов") + buffer_length = ( + len(payload) if buffer_lengths is None else int(buffer_lengths[callback_index]) + ) + if buffer_length < len(payload): + raise ValueError( + f"Callback {callback_index}: valid_length превышает buffer_length" + ) + received_bytes += len(payload) + if callback_index < warmup_callback_count: + continue + remaining_bytes = 2 * (target_sample_count - accepted_samples) + if remaining_bytes <= 0: + break + accepted = payload[:remaining_bytes] + accepted_parts.append(accepted) + accepted_count = len(accepted) // 2 + boundary = HackrfBoundary( + callback_index=callback_index, + valid_bytes=len(payload), + accepted_bytes=len(accepted), + accepted_start_sample=accepted_samples, + accepted_end_sample=accepted_samples + accepted_count, + monotonic_seconds=float(callback_index), + block_sha256=hashlib.sha256(payload).hexdigest(), + buffer_length=buffer_length, + ) + boundaries.append(boundary) + accepted_samples += accepted_count + if accepted_samples == target_sample_count: + break + if accepted_samples != target_sample_count: + raise RuntimeError( + f"Поток короче ожидаемого: {accepted_samples}/{target_sample_count} отсчётов" + ) + raw = b"".join(accepted_parts) + samples = signed_iq_bytes_to_complex64(raw) + callback_indexes = [row.callback_index for row in boundaries] + sequential = callback_indexes == list( + range(warmup_callback_count, warmup_callback_count + len(callback_indexes)) + ) + hashes = [row.block_sha256 for row in boundaries if row.accepted_bytes == row.valid_bytes] + diagnostics_row = { + "callback_count_total": min(len(blocks), warmup_callback_count + len(boundaries)), + "warmup_callback_count": warmup_callback_count, + "accepted_callback_count": len(boundaries), + "received_bytes_including_warmup": received_bytes, + "accepted_bytes": len(raw), + "accepted_sample_count": len(samples), + "callback_indexes_sequential": sequential, + "program_queue_missing_callback_count": 0 if sequential else None, + "duplicate_full_block_hash_count": len(hashes) - len(set(hashes)), + "observed_buffer_lengths": sorted( + { + len(block) if buffer_lengths is None else int(buffer_lengths[index]) + for index, block in enumerate(blocks) + } + ), + "observed_valid_lengths": sorted({len(block) for block in blocks}), + } + return samples, tuple(boundaries), diagnostics_row + + +class HackrfRxDevice: + """Минимальная RX-only ctypes-обёртка libhackrf.""" + + def __init__(self, dll_path: Path = HACKRF_DLL, serial: str = EXPECTED_SERIAL): + self.dll_path = Path(dll_path) + self.serial = serial + self._dll_directory = None + self.library = None + self.device = ctypes.c_void_p() + self._callback_reference = None + self._initialized = False + + def _load(self) -> None: + if not self.dll_path.exists(): + raise FileNotFoundError(f"libhackrf не найдена: {self.dll_path}") + if hasattr(os, "add_dll_directory"): + self._dll_directory = os.add_dll_directory(str(self.dll_path.parent)) + library = ctypes.CDLL(str(self.dll_path)) + library.hackrf_init.argtypes = [] + library.hackrf_init.restype = ctypes.c_int + library.hackrf_exit.argtypes = [] + library.hackrf_exit.restype = ctypes.c_int + library.hackrf_open_by_serial.argtypes = [ + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_void_p), + ] + library.hackrf_open_by_serial.restype = ctypes.c_int + library.hackrf_close.argtypes = [ctypes.c_void_p] + library.hackrf_close.restype = ctypes.c_int + library.hackrf_set_freq.argtypes = [ctypes.c_void_p, ctypes.c_uint64] + library.hackrf_set_freq.restype = ctypes.c_int + library.hackrf_set_sample_rate.argtypes = [ctypes.c_void_p, ctypes.c_double] + library.hackrf_set_sample_rate.restype = ctypes.c_int + library.hackrf_set_amp_enable.argtypes = [ctypes.c_void_p, ctypes.c_uint8] + library.hackrf_set_amp_enable.restype = ctypes.c_int + library.hackrf_set_lna_gain.argtypes = [ctypes.c_void_p, ctypes.c_uint32] + library.hackrf_set_lna_gain.restype = ctypes.c_int + library.hackrf_set_vga_gain.argtypes = [ctypes.c_void_p, ctypes.c_uint32] + library.hackrf_set_vga_gain.restype = ctypes.c_int + library.hackrf_set_antenna_enable.argtypes = [ctypes.c_void_p, ctypes.c_uint8] + library.hackrf_set_antenna_enable.restype = ctypes.c_int + library.hackrf_start_rx.argtypes = [ctypes.c_void_p, HackrfCallback, ctypes.c_void_p] + library.hackrf_start_rx.restype = ctypes.c_int + library.hackrf_stop_rx.argtypes = [ctypes.c_void_p] + library.hackrf_stop_rx.restype = ctypes.c_int + library.hackrf_is_streaming.argtypes = [ctypes.c_void_p] + library.hackrf_is_streaming.restype = ctypes.c_int + library.hackrf_error_name.argtypes = [ctypes.c_int] + library.hackrf_error_name.restype = ctypes.c_char_p + self.library = library + + def _check(self, code: int, action: str) -> None: + if code == 0: + return + name = "unknown" + if self.library is not None: + raw = self.library.hackrf_error_name(code) + name = raw.decode("ascii", errors="replace") if raw else "unknown" + raise RuntimeError(f"{action}: {name} ({code})") + + def open(self) -> None: + self._load() + self._check(self.library.hackrf_init(), "hackrf_init") + self._initialized = True + self._check( + self.library.hackrf_open_by_serial( + self.serial.encode("ascii"), ctypes.byref(self.device) + ), + "hackrf_open_by_serial", + ) + + def configure_rx(self) -> dict: + self._check( + self.library.hackrf_set_sample_rate(self.device, HACKRF_SAMPLE_RATE_HZ), + "hackrf_set_sample_rate", + ) + self._check( + self.library.hackrf_set_freq(self.device, HACKRF_CENTER_HZ), + "hackrf_set_freq", + ) + self._check( + self.library.hackrf_set_amp_enable(self.device, HACKRF_RF_AMP), + "hackrf_set_amp_enable", + ) + self._check( + self.library.hackrf_set_lna_gain(self.device, HACKRF_LNA_GAIN_DB), + "hackrf_set_lna_gain", + ) + self._check( + self.library.hackrf_set_vga_gain(self.device, HACKRF_VGA_GAIN_DB), + "hackrf_set_vga_gain", + ) + self._check( + self.library.hackrf_set_antenna_enable( + self.device, + HACKRF_ANTENNA_BIAS, + ), + "hackrf_set_antenna_enable", + ) + return { + "requested_center_frequency_hz": HACKRF_CENTER_HZ, + "set_center_frequency_hz": HACKRF_CENTER_HZ, + "requested_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ, + "set_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ, + "sample_rate_readback_available": False, + "center_frequency_readback_available": False, + "rf_amp_enabled": False, + "lna_gain_db": HACKRF_LNA_GAIN_DB, + "vga_gain_db": HACKRF_VGA_GAIN_DB, + "antenna_bias_enabled": False, + } + + def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict: + """Безопасно задать RX-усиления, повторно фиксируя RF amp и bias OFF.""" + + lna, vga = validate_hackrf_rx_gains(lna_gain_db, vga_gain_db) + self._check( + self.library.hackrf_set_amp_enable(self.device, HACKRF_RF_AMP), + "hackrf_set_amp_enable(OFF)", + ) + self._check( + self.library.hackrf_set_antenna_enable( + self.device, + HACKRF_ANTENNA_BIAS, + ), + "hackrf_set_antenna_enable(OFF)", + ) + self._check( + self.library.hackrf_set_lna_gain(self.device, lna), + "hackrf_set_lna_gain", + ) + self._check( + self.library.hackrf_set_vga_gain(self.device, vga), + "hackrf_set_vga_gain", + ) + return { + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "lna_gain_db": lna, + "vga_gain_db": vga, + "gain_readback_available": False, + "set_calls_succeeded": True, + } + + def capture( + self, + target_sample_count: int, + timeout_seconds: float = HACKRF_STREAM_TIMEOUT_SECONDS, + ) -> HackrfCapture: + """Принять заданное число отсчётов, сохранив исходные callback-байты.""" + + if not np.isfinite(timeout_seconds) or timeout_seconds <= 0.0: + raise ValueError("Тайм-аут HackRF RX должен быть положительным") + blocks: list[bytes] = [] + callback_buffer_lengths: list[int] = [] + callback_times: list[float] = [] + ready = threading.Event() + enough = threading.Event() + callback_error: list[str] = [] + accepted_samples = 0 + callback_index = 0 + lock = threading.Lock() + + @HackrfCallback + def receive_callback(transfer_pointer) -> int: + nonlocal accepted_samples, callback_index + try: + transfer = transfer_pointer.contents + payload = copy_valid_callback_bytes( + transfer.buffer, + transfer.buffer_length, + transfer.valid_length, + ) + now = time.perf_counter() + with lock: + index = callback_index + callback_index += 1 + blocks.append(payload) + callback_buffer_lengths.append(int(transfer.buffer_length)) + callback_times.append(now) + if index >= HACKRF_WARMUP_CALLBACK_COUNT: + accepted_samples += len(payload) // 2 + ready.set() + if accepted_samples >= target_sample_count: + enough.set() + return 0 + except Exception as error: # pragma: no cover - аппаратный callback + callback_error.append(f"{type(error).__name__}: {error}") + enough.set() + return 1 + + self._callback_reference = receive_callback + started_at = time.perf_counter() + self._check( + self.library.hackrf_start_rx(self.device, receive_callback, None), + "hackrf_start_rx", + ) + if not ready.wait(timeout=5.0): + self.library.hackrf_stop_rx(self.device) + raise RuntimeError("HackRF RX не вернул первый рабочий callback") + if not enough.wait(timeout=timeout_seconds): + self.library.hackrf_stop_rx(self.device) + raise TimeoutError("HackRF RX не набрал заданное число отсчётов") + stop_code = self.library.hackrf_stop_rx(self.device) + self._check(stop_code, "hackrf_stop_rx") + stopped_at = time.perf_counter() + if callback_error: + raise RuntimeError("; ".join(callback_error)) + with lock: + frozen_blocks = list(blocks) + frozen_buffer_lengths = list(callback_buffer_lengths) + frozen_times = list(callback_times) + samples, synthetic_boundaries, assembly = assemble_callback_blocks( + frozen_blocks, + target_sample_count, + HACKRF_WARMUP_CALLBACK_COUNT, + frozen_buffer_lengths, + ) + raw_bytes = b"".join( + frozen_blocks[row.callback_index][: row.accepted_bytes] + for row in synthetic_boundaries + ) + if len(raw_bytes) != 2 * target_sample_count: + raise RuntimeError("Размер сохранённых int8-байтов не совпал с raw IQ") + if not np.array_equal(signed_iq_bytes_to_complex64(raw_bytes), samples): + raise RuntimeError("Исходные int8-байты не совпали с преобразованным raw IQ") + real_boundaries = [] + for row in synthetic_boundaries: + real_boundaries.append( + HackrfBoundary( + callback_index=row.callback_index, + valid_bytes=row.valid_bytes, + accepted_bytes=row.accepted_bytes, + accepted_start_sample=row.accepted_start_sample, + accepted_end_sample=row.accepted_end_sample, + monotonic_seconds=frozen_times[row.callback_index] - started_at, + block_sha256=row.block_sha256, + buffer_length=row.buffer_length, + ) + ) + first_time = frozen_times[HACKRF_WARMUP_CALLBACK_COUNT] + last_time = frozen_times[real_boundaries[-1].callback_index] + interval_seconds = max( + last_time - first_time + + real_boundaries[-1].valid_bytes / (2.0 * HACKRF_SAMPLE_RATE_HZ), + 1e-12, + ) + callback_stream_samples = sum(row.valid_bytes for row in real_boundaries) / 2.0 + assembly.update( + { + "stream_started_monotonic": started_at, + "stream_stopped_monotonic": stopped_at, + "stream_wall_seconds": stopped_at - started_at, + "accepted_interval_seconds": interval_seconds, + "mean_callback_stream_sample_rate_hz": ( + callback_stream_samples / interval_seconds + ), + "expected_callback_bytes": HACKRF_EXPECTED_CALLBACK_BYTES, + "observed_callback_byte_sizes": sorted( + {len(block) for block in frozen_blocks} + ), + "observed_callback_buffer_lengths": sorted( + set(frozen_buffer_lengths) + ), + "usb_loss_counter_available": False, + "usb_loss_claim": None, + "usb_loss_claim_status": "not_computed", + "usb_loss_claim_reason": ( + "libhackrf callback API не предоставляет счётчик потерь" + ), + } + ) + return HackrfCapture(samples, tuple(real_boundaries), assembly, raw_bytes) + + def close(self) -> None: + if self.library is not None and self.device.value: + self._check(self.library.hackrf_close(self.device), "hackrf_close") + self.device = ctypes.c_void_p() + if self.library is not None and self._initialized: + self._check(self.library.hackrf_exit(), "hackrf_exit") + self._initialized = False + if self._dll_directory is not None: + self._dll_directory.close() + self._dll_directory = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, exc_type, exc, traceback): + self.close() + return False + + +def query_hackrf_info() -> dict: + completed = subprocess.run( + [str(HACKRF_INFO)], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + text = completed.stdout + completed.stderr + + def value(prefix: str) -> str: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith(prefix): + return stripped.split(":", 1)[1].strip() + raise RuntimeError(f"hackrf_info не вернул поле {prefix}") + + serial = value("Serial number") + if serial != EXPECTED_SERIAL: + raise RuntimeError(f"Неожиданный серийный номер HackRF: {serial}") + return { + "model": "HackRF One", + "serial_number": serial, + "firmware_version": value("Firmware Version"), + "libhackrf_version": value("libhackrf version"), + "hardware_revision": value("Hardware Revision"), + "hackrf_info_version": value("hackrf_info version"), + "information_query": text.strip(), + } + + +def preflight_hackrf( + lna_gain_db: int = HACKRF_LNA_GAIN_DB, + vga_gain_db: int = HACKRF_VGA_GAIN_DB, +) -> dict: + information = query_hackrf_info() + with HackrfRxDevice() as receiver: + parameters = receiver.configure_rx() + gain_parameters = receiver.set_rx_gains(lna_gain_db, vga_gain_db) + probe = receiver.capture(HACKRF_PROBE_SAMPLE_COUNT) + samples = probe.samples + raw_i = np.rint(samples.real * 128.0) + raw_q = np.rint(samples.imag * 128.0) + return information | parameters | gain_parameters | { + "device_opened_via_libhackrf": True, + "rx_started": True, + "probe_sample_count": len(samples), + "probe_dtype": str(samples.dtype), + "probe_mean_i": float(np.mean(samples).real), + "probe_mean_q": float(np.mean(samples).imag), + "probe_rms": float(np.sqrt(np.mean(np.abs(samples) ** 2))), + "probe_peak_magnitude": float(np.max(np.abs(samples))), + "probe_clipped_component_fraction": float( + np.mean((np.abs(raw_i) >= 127) | (np.abs(raw_q) >= 127)) + ), + "probe_stream": probe.diagnostics, + } + + +def _waveform_metadata(plan: lab043.PrbsTransmissionPlan, waveform_sha256: str) -> dict: + return { + "calibration_tones_hz": [-lab043.F_CAL_HZ, lab043.F_CAL_HZ], + "calibration_duration_seconds": lab043.CALIBRATION_TONE_DURATION_SECONDS, + "fixed_guard_seconds": lab043.CALIBRATION_TO_BPSK_GUARD_SECONDS, + "single_initial_marker_bit_count": len(plan.marker_bits), + "pilot_symbol_count": len(plan.pilot_bits), + "pilot_duration_seconds": len(plan.pilot_bits) / lab043.SYMBOL_RATE, + "pilot_polynomial": lab043.PILOT_POLYNOMIAL, + "pilot_initial_state_hex": f"0x{lab043.PILOT_INITIAL_STATE:02X}", + "pilot_sha256": hashlib.sha256(plan.pilot_bits.tobytes()).hexdigest(), + "periodic_resynchronization": False, + "prbs_polynomial": lab043.PRBS11_POLYNOMIAL, + "prbs_initial_state_hex": f"0x{lab043.PRBS11_INITIAL_STATE:03X}", + "prbs_useful_duration_seconds": plan.useful_duration_seconds, + "transmitted_prbs_bit_count": len(plan.payload_bits), + "transmitted_prbs_sha256": hashlib.sha256(plan.payload_bits.tobytes()).hexdigest(), + "tx_sample_count": len(plan.tx_samples), + "tx_samples_sha256": waveform_sha256, + } + + +def _capture_triplets(capture: diagnostics.CaptureAnalysis) -> list[dict]: + return diagnostics.triplet_rows( + capture.name, + capture.payload_bits, + capture.payload_normalized, + ) + + +def save_comparison_artifacts( + hackrf_metadata_path: Path, + output_directory: Path = PROCESSED_DIRECTORY, +) -> tuple[Path, ...]: + output_directory.mkdir(parents=True, exist_ok=True) + hackrf = diagnostics.load_capture( + hackrf_metadata_path.parent, + hackrf_metadata_path.name, + require_processing_success=False, + ) + rtl_raw = Path("data/raw/lab043") + rtl_captures = [ + diagnostics.load_capture(rtl_raw, name) + for name in diagnostics.VALID_CAPTURE_NAMES + ] + captures = rtl_captures + [hackrf] + metadata_documents = { + capture.name: capture.metadata for capture in captures + } + summary_rows = [] + triplet_rows = [] + run_rows = [] + dc_rows = [] + phase_rows = [] + for capture in captures: + metadata = metadata_documents[capture.name] + mode_b = metadata["analysis"]["modes"]["B"] + summary_rows.append( + { + "receiver": "HackRF" if capture is hackrf else "RTL-SDR", + "capture": capture.name, + "marker_detected": mode_b["detected"], + "marker_correlation": mode_b["marker_correlation"], + "coarse_carrier_offset_hz": metadata["analysis"]["calibration"]["carrier_offset_hz"], + "carrier_offset_ppm": metadata["analysis"]["calibration"]["carrier_offset_ppm"], + "sample_clock_error_ppm": metadata["analysis"]["calibration"]["sample_clock_error_ppm"], + "mode_b_bit_errors": mode_b["bit_errors"], + "mode_b_ber": mode_b["bit_error_rate"], + "mode_b_quarter_bers": json.dumps(mode_b["quarter_bit_error_rates"]), + "mode_b_evm_percent": mode_b["evm_percent"], + "mode_b_maximum_correct_run_bits": mode_b["maximum_correct_run_bits"], + "peak_magnitude": metadata["overload"]["peak_magnitude"], + "clipped_component_fraction": metadata["overload"]["clipped_component_fraction"], + } + ) + triplet_rows.extend(_capture_triplets(capture)) + run_rows.extend(diagnostics.run_length_rows(capture)) + dc_rows.extend(diagnostics.dc_iq_rows(capture)) + phase_rows.extend(diagnostics.phase_sweep_rows(capture)) + channel_models, channel_coefficients, _channel_triplets = ( + diagnostics.fitted_symbol_channel_rows(captures) + ) + diagnostics.write_csv(output_directory / "summary.csv", summary_rows) + diagnostics.write_csv(output_directory / "triplets.csv", triplet_rows) + diagnostics.write_csv(output_directory / "run_lengths.csv", run_rows) + diagnostics.write_csv(output_directory / "dc_iq.csv", dc_rows) + diagnostics.write_csv(output_directory / "channel_models.csv", channel_models) + diagnostics.write_csv( + output_directory / "channel_coefficients.csv", channel_coefficients + ) + diagnostics.save_plots(output_directory, phase_rows, run_rows) + + lines = [ + "Lab043: сравнительный короткий S, RTL-SDR и HackRF", + "", + "Основной режим: двухтональная грубая коррекция, без пилотной тонкой коррекции и без SRO.", + ] + for row in summary_rows: + lines.append( + f"- {row['receiver']} / {row['capture']}: маркер={row['marker_detected']}, " + f"corr={float(row['marker_correlation']):.6f}, CFO={float(row['coarse_carrier_offset_hz']):+.3f} Гц, " + f"SRO={float(row['sample_clock_error_ppm']):+.3f} ppm, " + f"ошибок={row['mode_b_bit_errors']}/5000, BER={float(row['mode_b_ber']):.6f}, " + f"EVM={float(row['mode_b_evm_percent']):.3f}%." + ) + lines.append("") + lines.append("Тройки после общей диагностической линейной фазовой коррекции по известной PRBS:") + for capture in captures: + selected = [row for row in triplet_rows if row["source"] == capture.name] + counts = {row["triplet"]: row["bit_errors"] for row in selected} + lines.append( + f"- {capture.name}: 000={counts['000']}, 001={counts['001']}, " + f"остальные={sum(counts[key] for key in diagnostics.TRIPLETS if key not in {'000', '001'})}." + ) + lines.append("") + lines.append( + "Полные численные таблицы находятся в summary.csv, triplets.csv, run_lengths.csv, " + "dc_iq.csv и channel_coefficients.csv. Классификация A/B/C/D определяется только по факту." + ) + report_path = output_directory / "report.txt" + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return tuple(sorted(output_directory.iterdir())) + + +def run_one_hackrf_s( + raw_directory: Path = RAW_DIRECTORY, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Выполнить ровно один S; функция никогда не повторяет передачу.""" + + plan = lab043.build_prbs11_transmission_plan( + "S", lab043.PRBS_SHORT_DURATION_SECONDS + ) + waveform_sha256 = hashlib.sha256( + np.asarray(plan.tx_samples, dtype=np.complex64).tobytes() + ).hexdigest() + expected_rtl_waveform_sha = "981aeddde3ed63192c24bfbe8a11f58b2896b50a643dd5faf3f81eaa9c2bf0bc" + if waveform_sha256 != expected_rtl_waveform_sha: + raise RuntimeError( + "Waveform S не совпал с последним валидным RTL-SDR S; TX запрещён" + ) + + pluto_preflight = rtl_hardware.preflight_pluto() + hackrf_preflight = preflight_hackrf() + pluto, tx_parameters = rtl_hardware.configure_pluto() + actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) + expected_without_callback_margin = lab043.receive_sample_count( + len(plan.tx_samples), actual_tx_rate, HACKRF_SAMPLE_RATE_HZ + ) + target_samples = expected_without_callback_margin + HACKRF_EXPECTED_CALLBACK_BYTES // 2 + capture_started = datetime.now(timezone.utc) + ready = threading.Event() + tx_error: list[str] = [] + tx_hold_seconds: list[float] = [] + capture_holder: list[HackrfCapture] = [] + + with HackrfRxDevice() as receiver: + rx_parameters = receiver.configure_rx() + + def capture_rx() -> None: + try: + # capture() подтверждает первый рабочий callback внутренне; внешний ready + # выставляется сразу после старта потока посредством короткой проверки is_streaming. + capture_holder.append(receiver.capture(target_samples)) + except Exception as error: # pragma: no cover - аппаратный путь + tx_error.append(f"RX {type(error).__name__}: {error}") + + rx_thread = threading.Thread(target=capture_rx, daemon=True) + rx_thread.start() + deadline = time.perf_counter() + 5.0 + while time.perf_counter() < deadline: + if receiver.library.hackrf_is_streaming(receiver.device) == 1: + ready.set() + break + if tx_error: + break + time.sleep(0.01) + if not ready.is_set(): + rx_thread.join(timeout=1.0) + raise RuntimeError("HackRF RX не подтвердил поток; Pluto TX не включён") + time.sleep(lab043.RX_LEADING_MARGIN_SECONDS) + try: + tx_hold_seconds.append( + rtl_hardware.transmit_noncyclic_buffer_once( + pluto, + lab043.to_pluto_tx_samples(plan.tx_samples), + actual_tx_rate, + ) + ) + except Exception as error: # pragma: no cover - аппаратный путь + tx_error.append(f"TX {type(error).__name__}: {error}") + rx_thread.join(timeout=HACKRF_STREAM_TIMEOUT_SECONDS + 5.0) + if rx_thread.is_alive(): + raise RuntimeError("Непрерывный поток HackRF не завершился") + if tx_error: + raise RuntimeError("; ".join(tx_error)) + if len(capture_holder) != 1: + raise RuntimeError("HackRF не вернул единственный массив RX") + capture = capture_holder[0] + received = capture.samples + if len(received) != target_samples or not np.all(np.isfinite(received)): + raise RuntimeError("Полученный массив HackRF неполон или содержит нечисловые значения") + + raw_i = np.rint(received.real * 128.0) + raw_q = np.rint(received.imag * 128.0) + overload = { + "peak_magnitude": float(np.max(np.abs(received))), + "clipped_component_fraction": float( + np.mean((np.abs(raw_i) >= 127) | (np.abs(raw_q) >= 127)) + ), + } + boundary_rows = [asdict(row) for row in capture.boundaries] + calibration_end_seconds = plan.calibration_sample_count / actual_tx_rate + guard_end_seconds = plan.bpsk_start_sample / actual_tx_rate + tx_duration = len(plan.tx_samples) / actual_tx_rate + metadata = { + "experiment": "Lab043 HackRF comparative PRBS11 continuous RX", + "capture_label": "S", + "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), + "capture_status": "captured", + "processing_status": "pending", + "processing_error": None, + "receiver_model": "HackRF", + "actual_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ, + "center_frequency_hz": HACKRF_CENTER_HZ, + "rx_gain_db": HACKRF_LNA_GAIN_DB + HACKRF_VGA_GAIN_DB, + "pluto_preflight": pluto_preflight, + "hackrf_preflight": hackrf_preflight, + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters | { + "serial_number": hackrf_preflight["serial_number"], + "firmware_version": hackrf_preflight["firmware_version"], + "libhackrf_version": hackrf_preflight["libhackrf_version"], + }, + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> SMA/adapter -> HackRF ANTENNA; HackRF RX only", + "waveform_sha256": waveform_sha256, + "tx_waveform_duration_seconds": tx_duration, + "callback_count": capture.diagnostics["callback_count_total"], + "callback_boundaries": boundary_rows, + "expected_intervals": { + "relative_to_tx_start_seconds": { + "calibration": [0.0, calibration_end_seconds], + "guard": [calibration_end_seconds, guard_end_seconds], + "bpsk_marker_pilot_prbs": [guard_end_seconds, tx_duration], + }, + "rx_leading_margin_seconds": lab043.RX_LEADING_MARGIN_SECONDS, + "rx_trailing_margin_seconds_minimum": lab043.RX_TRAILING_MARGIN_SECONDS, + "extra_callback_margin_samples": HACKRF_EXPECTED_CALLBACK_BYTES // 2, + }, + "waveform": _waveform_metadata(plan, waveform_sha256), + "continuous_rx": capture.diagnostics | { + "boundaries": boundary_rows, + "program_continuity_confirmed": bool( + capture.diagnostics["callback_indexes_sequential"] + and capture.diagnostics["program_queue_missing_callback_count"] == 0 + and capture.diagnostics["duplicate_full_block_hash_count"] == 0 + ), + "usb_continuity_proven": False, + }, + "tx_buffer_hold_seconds": tx_hold_seconds[0] if tx_hold_seconds else None, + "overload": overload, + } + stamp = capture_started.strftime("%Y%m%d_%H%M%S") + base = raw_directory / f"lab043_hackrf_prbs11_s_{stamp}" + iq_path, metadata_path, analysis, processing_error = lab043.save_then_process_capture( + base, + received, + metadata, + lambda stored: rtl_hardware.analyze_captured_prbs( + stored, + plan, + HACKRF_SAMPLE_RATE_HZ, + actual_tx_rate, + capture.boundaries, + ), + ) + result = { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "waveform_sha256": waveform_sha256, + "capture_status": "captured", + "processing_status": "processing_failed" if analysis is None else "success", + "processing_error": processing_error, + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters, + "continuous_rx": capture.diagnostics, + "overload": overload, + } + if analysis is not None: + result.update( + { + "processing_success": analysis.processing_success, + "invalid_reason": analysis.invalid_reason, + "calibration": asdict(analysis.calibration), + "continuity": analysis.continuity, + "modes": { + mode: asdict(metrics) for mode, metrics in analysis.modes.items() + }, + } + ) + if analysis.processing_success: + save_comparison_artifacts(metadata_path, processed_directory) + else: + processed_directory.mkdir(parents=True, exist_ok=True) + diagnostics.write_csv( + processed_directory / "summary.csv", + [ + { + "receiver": "HackRF", + "capture": metadata_path.stem, + "processing_success": False, + "invalid_reason": analysis.invalid_reason, + "peak_magnitude": overload["peak_magnitude"], + "clipped_component_fraction": overload[ + "clipped_component_fraction" + ], + } + ], + ) + (processed_directory / "report.txt").write_text( + "Lab043 HackRF: отрицательный результат первого S\n\n" + f"Raw сохранён: {iq_path}\n" + f"Причина недостоверности: {analysis.invalid_reason}\n" + "Усиление не изменялось, повторный TX не выполнялся.\n", + encoding="utf-8", + ) + return result + + +def save_negative_comparison_artifacts( + document: dict, + analysis: rtl_hardware.CapturedPrbsAnalysis, + iq_path: Path, + output_directory: Path, +) -> tuple[Path, ...]: + """Зафиксировать недействительный S без выдуманных BER и кластеров.""" + + from matplotlib import pyplot as plt + + output_directory.mkdir(parents=True, exist_ok=True) + mode_b = analysis.modes["B"] + calibration = analysis.calibration + stream = document["continuous_rx"] + summary = { + "receiver": "HackRF", + "capture": iq_path.stem, + "result_valid": False, + "invalid_reason": analysis.invalid_reason or mode_b.failure_reason, + "raw_iq_sha256": document["iq_sha256"], + "sample_count": document["sample_count"], + "marker_detected": mode_b.detected, + "marker_correlation": float("nan"), + "carrier_offset_hz_preliminary": calibration.carrier_offset_hz, + "carrier_offset_ppm_preliminary": calibration.carrier_offset_ppm, + "sample_clock_error_ppm_preliminary": calibration.sample_clock_error_ppm, + "phase_fit_rmse_rad": calibration.phase_fit_rmse_rad, + "mode_b_bit_errors": None, + "mode_b_ber": None, + "mode_b_evm_percent": None, + "mode_b_metrics_status": "not_computed", + "peak_magnitude": document["overload"]["peak_magnitude"], + "clipped_component_fraction": document["overload"][ + "clipped_component_fraction" + ], + "callback_count": stream["callback_count_total"], + "accepted_callback_count": stream["accepted_callback_count"], + "program_continuity_confirmed": stream["program_continuity_confirmed"], + "usb_continuity_proven": stream["usb_continuity_proven"], + "mean_callback_stream_sample_rate_hz": stream[ + "mean_callback_stream_sample_rate_hz" + ], + "confirmed_phase_discontinuities": analysis.continuity[ + "confirmed_discontinuity_count" + ], + "maximum_phase_jump_rad": analysis.continuity[ + "maximum_absolute_phase_jump_rad" + ], + "classification": "D", + } + diagnostics.write_csv(output_directory / "summary.csv", [summary]) + diagnostics.write_csv( + output_directory / "triplets.csv", + [ + { + "triplet": triplet, + "status": "not_computed", + "reason": "захват недействителен из-за фазовых разрывов", + } + for triplet in diagnostics.TRIPLETS + ], + ) + diagnostics.write_csv( + output_directory / "run_lengths.csv", + [ + { + "bit": bit, + "run_length": length, + "status": "not_computed", + "reason": "захват недействителен из-за фазовых разрывов", + } + for bit in (0, 1) + for length in ("1", "2", "3", "4", "5", "6", "7+") + ], + ) + diagnostics.write_csv( + output_directory / "continuity.csv", + analysis.continuity["boundaries"], + ) + + samples = np.load(iq_path, allow_pickle=False) + sample_rate = float(document["actual_sample_rate_hz"]) + block_samples = 8192 + hop_samples = 4096 + centered = samples - np.mean(samples[: round(0.3 * sample_rate)]) + starts = np.arange(0, len(samples) - block_samples + 1, hop_samples) + powers = np.asarray( + [ + np.mean(np.abs(centered[start : start + block_samples]) ** 2) + for start in starts + ] + ) + baseline_limit = max(3, int(0.30 * sample_rate / hop_samples)) + baseline = float(np.median(powers[:baseline_limit])) + excess_db = 10.0 * np.log10(np.maximum(powers, 1e-30) / max(baseline, 1e-30)) + maximum_broadband_excess_db = float(np.max(excess_db)) + sections = asdict(analysis)["sections"] + boundary_rows = analysis.continuity["boundaries"] + + figure, axes = plt.subplots(2, 1, figsize=(10, 8)) + axes[0].plot(starts / sample_rate, excess_db) + axes[0].axhline(10.0, color="tab:red", linestyle="--", label="Фиксированный порог 10 дБ") + axes[0].axvspan( + sections["calibration_start_sample"] / sample_rate, + sections["calibration_end_sample"] / sample_rate, + color="tab:green", + alpha=0.15, + label="Найденный двухтоновый участок", + ) + axes[0].set_title("HackRF S: широкополосная мощность") + axes[0].set_xlabel("Время от начала сохранённого raw, с") + axes[0].set_ylabel("Превышение над фоном, дБ") + axes[0].grid(True, alpha=0.3) + axes[0].legend() + boundary_times = [row["boundary_sample"] / sample_rate for row in boundary_rows] + axes[1].plot( + boundary_times, + [row["low_phase_jump_rad"] for row in boundary_rows], + marker="o", + label="Нижний тон", + ) + axes[1].plot( + boundary_times, + [row["high_phase_jump_rad"] for row in boundary_rows], + marker="o", + label="Верхний тон", + ) + axes[1].axhline(0.25, color="tab:red", linestyle="--") + axes[1].axhline(-0.25, color="tab:red", linestyle="--", label="Предел ±0,25 рад") + axes[1].set_title("Фазовые скачки на callback-границах") + axes[1].set_xlabel("Время границы, с") + axes[1].set_ylabel("Скачок фазы, рад") + axes[1].grid(True, alpha=0.3) + axes[1].legend() + figure.tight_layout() + figure.savefig(output_directory / "negative_capture_diagnostics.png", dpi=150) + plt.close(figure) + + report = f"""Lab043: единственный сравнительный S с HackRF + +Результат: недействителен для BER и локализации асимметрии 000/001. +Классификация: D, данных недостаточно для локализации. + +Raw: {iq_path} +SHA-256: {document['iq_sha256']} +Waveform SHA-256: {document['waveform_sha256']} + +HackRF: {document['rx_parameters']['serial_number']}, firmware {document['rx_parameters']['firmware_version']}, libhackrf {document['rx_parameters']['libhackrf_version']}. +RX: 435 МГц, запрос 2 400 000 отсчётов/с, RF amp OFF, LNA 8 дБ, VGA 8 дБ, bias OFF. +Pluto+: фактически {document['tx_parameters']['actual_center_frequency_hz']} Гц, {document['tx_parameters']['actual_sample_rate_hz']} отсчётов/с, gain {document['tx_parameters']['actual_gain_db']} дБ, RF bandwidth {document['tx_parameters']['actual_rf_bandwidth_hz']} Гц. + +Программная сборка: {stream['accepted_callback_count']} рабочих callback после одного заранее отброшенного разогревочного; индексы последовательны, дубликатов полных блоков {stream['duplicate_full_block_hash_count']}, пропусков очереди {stream['program_queue_missing_callback_count']}. +Средняя callback-скорость: {stream['mean_callback_stream_sample_rate_hz']:.3f} отсчётов/с. +USB-потери аппаратно не доказаны и не опровергнуты: внутреннего счётчика API нет. + +Два тона найдены неизменным спектральным порогом 10 дБ: нижний +{calibration.peak_margin_low_db:.3f} дБ, верхний +{calibration.peak_margin_high_db:.3f} дБ. +Максимальный рост полной мощности: только {maximum_broadband_excess_db:.3f} дБ, поэтому исходный широкополосный локатор не сработал. +Предварительная оценка CFO: {calibration.carrier_offset_hz:+.3f} Гц ({calibration.carrier_offset_ppm:+.6f} ppm). +Предварительная оценка SRO: {calibration.sample_clock_error_ppm:+.3f} ppm. +Эти оценки нельзя считать итоговыми: RMSE фазовой модели {calibration.phase_fit_rmse_rad:.3f} рад и в калибровке подтверждено {analysis.continuity['confirmed_discontinuity_count']} фазовых разрыва из {analysis.continuity['checked_boundary_count']} проверенных границ; максимум {analysis.continuity['maximum_absolute_phase_jump_rad']:.3f} рад при пределе 0,25 рад. + +Маркер не вычислялся. +BER (доля ошибочных битов) не вычислялась. +BER по четвертям не вычислялась. +EVM (величина вектора ошибки) не вычислялась. +Тройки 000–111, серии и кластеры не вычислялись, чтобы не выдавать недостоверный захват за измерение. + +Сравнение с RTL-SDR невозможно: два RTL-SDR S валидны, а HackRF S не прошёл контроль непрерывности. Главный текущий подозреваемый не 000/001-механизм, а достоверность приёма HackRF на минимальном усилении и/или непрерывность USB/драйвера. Усиления и пороги не менялись, повторный TX не выполнялся. + +Следующий шаг, пока не выполненный: отдельно доказать непрерывность HackRF RX и достаточную фазовую точность на 2,4 Мвыб/с без изменения протокола; только затем отдельно решить, разрешать ли новый S или изменение усиления. +""" + (output_directory / "report.txt").write_text(report, encoding="utf-8") + return tuple(sorted(output_directory.iterdir())) + + +def reprocess_saved_capture( + metadata_path: Path, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Повторно обработать только уже сохранённый raw, не открывая SDR.""" + + metadata_path = Path(metadata_path) + document = json.loads(metadata_path.read_text(encoding="utf-8")) + iq_path = metadata_path.parent / document["iq_file"] + actual_sha = hashlib.sha256(iq_path.read_bytes()).hexdigest() + if not document.get("raw_verified") or actual_sha != document.get("iq_sha256"): + raise RuntimeError("SHA-256 сохранённого raw не прошёл повторную проверку") + samples = np.load(iq_path, allow_pickle=False) + if samples.dtype != np.dtype(np.complex64) or len(samples) != document["sample_count"]: + raise RuntimeError("Формат сохранённого raw не совпал с JSON") + plan = lab043.build_prbs11_transmission_plan( + "S", lab043.PRBS_SHORT_DURATION_SECONDS + ) + waveform_sha256 = hashlib.sha256( + np.asarray(plan.tx_samples, dtype=np.complex64).tobytes() + ).hexdigest() + if waveform_sha256 != document["waveform_sha256"]: + raise RuntimeError("Waveform сохранённого опыта не совпал с текущим детерминированным S") + boundaries = tuple( + HackrfBoundary(**row) for row in document["callback_boundaries"] + ) + try: + analysis = rtl_hardware.analyze_captured_prbs( + samples, + plan, + float(document["actual_sample_rate_hz"]), + float(document["tx_parameters"]["actual_sample_rate_hz"]), + boundaries, + ) + except Exception as error: + message = f"{type(error).__name__}: {error}" + lab043.update_processing_metadata( + metadata_path, + processing_status="processing_failed", + processing_error=message, + ) + return { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "processing_status": "processing_failed", + "processing_error": message, + } + lab043.update_processing_metadata( + metadata_path, + processing_status="success", + processing_error=None, + analysis=asdict(analysis), + ) + mode_b = analysis.modes["B"] + result = { + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "iq_sha256": actual_sha, + "processing_status": "success", + "processing_success": analysis.processing_success, + "invalid_reason": analysis.invalid_reason, + "calibration": asdict(analysis.calibration), + "continuity": analysis.continuity, + "modes": {mode: asdict(metrics) for mode, metrics in analysis.modes.items()}, + } + mode_b_valid = bool( + mode_b.detected and mode_b.matched_bit_count == len(plan.payload_bits) + ) + if mode_b_valid: + save_comparison_artifacts(metadata_path, processed_directory) + else: + refreshed_document = json.loads(metadata_path.read_text(encoding="utf-8")) + save_negative_comparison_artifacts( + refreshed_document, + analysis, + iq_path, + processed_directory, + ) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--preflight-only", action="store_true") + group.add_argument("--run-one-s", action="store_true") + group.add_argument("--reprocess-metadata", type=Path) + arguments = parser.parse_args() + if arguments.preflight_only: + result = { + "pluto": rtl_hardware.preflight_pluto(), + "hackrf": preflight_hackrf(), + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if arguments.reprocess_metadata is not None: + result = reprocess_saved_capture(arguments.reprocess_metadata) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["processing_status"] == "success" else 2 + result = run_one_hackrf_s() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["capture_status"] == "captured" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_hackrf_zero_if_compare.py b/experiments/lab043_hackrf_zero_if_compare.py new file mode 100644 index 0000000..0f3561a --- /dev/null +++ b/experiments/lab043_hackrf_zero_if_compare.py @@ -0,0 +1,1141 @@ +"""Один составной опыт сравнения цифрового счётчика около 0 и +50 кГц. + +Скрипт передаёт ровно один нециклический буфер Pluto+ и принимает его одним +непрерывным RX-сеансом HackRF. Исходные signed int8 I/Q-байты сохраняются до +преобразования вместе с полученным из них массивом complex64. +""" + +from __future__ import annotations + +import argparse +import csv +import gc +import hashlib +import json +import math +import sys +import threading +import time +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +from scipy.signal import fftconvolve, welch + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from experiments import lab043_hackrf_counter as counter + + +TONE_OFFSET_HZ = 50_000.0 +ZERO_SECONDS = 0.5 +TONE_SECONDS = 0.5 +GUARD_SECONDS = 0.25 +RX_LEADING_SECONDS = 0.75 +RX_TRAILING_SECONDS = 0.75 +TX_UPLOAD_MARGIN_SECONDS = 10.0 +RX_TIMEOUT_SECONDS = 35.0 +TONE_SEARCH_HALF_WIDTH_HZ = 5_000.0 +TONE_LOCAL_NOISE_GUARD_HZ = 2_000.0 +COARSE_SEARCH_SECONDS = 0.25 +MATCH_REFERENCE_START_SYMBOL = 10_000 +MATCH_REFERENCE_SYMBOL_COUNT = 30_000 +CFO_BLOCK_SYMBOLS = 128 +RAW_DIRECTORY = Path("data/raw/lab043_hackrf") +PROCESSED_DIRECTORY = Path("data/processed/lab043_hackrf_zero_if_compare") + + +def _section( + name: str, + start_sample: int, + sample_count: int, + sample_rate_hz: float, + purpose: str, +) -> dict: + end_sample = start_sample + sample_count + return { + "name": name, + "purpose": purpose, + "start_sample": int(start_sample), + "end_sample_exclusive": int(end_sample), + "sample_count": int(sample_count), + "start_seconds": float(start_sample / sample_rate_hz), + "end_seconds": float(end_sample / sample_rate_hz), + "duration_seconds": float(sample_count / sample_rate_hz), + } + + +def build_composite_waveform(actual_sample_rate_hz: float) -> tuple[np.ndarray, dict]: + """Построить семь заранее фиксированных участков одного TX-буфера.""" + + counter_waveform, counter_parameters = counter.build_counter_waveform( + actual_sample_rate_hz + ) + zero_samples = int(round(ZERO_SECONDS * actual_sample_rate_hz)) + tone_samples = int(round(TONE_SECONDS * actual_sample_rate_hz)) + guard_samples = int(round(GUARD_SECONDS * actual_sample_rate_hz)) + counts = ( + zero_samples, + tone_samples, + guard_samples, + len(counter_waveform), + guard_samples, + len(counter_waveform), + zero_samples, + ) + names = ( + "leading_zero", + "tone_plus_50khz", + "counter_plus_50khz", + "counter_plus_50khz", + "guard_after_shifted_counter", + "counter_zero_if", + "trailing_zero", + ) + purposes = ( + "известный участок без полезного сигнала", + "одиночный контрольный тон +50 кГц", + "защитный интервал", + "цифровой счётчик +50 кГц", + "защитный интервал", + "тот же цифровой счётчик около 0 Гц", + "известный участок после сигнала", + ) + # Исправить имя третьего участка отдельно, сохраняя явный порядок семи секций. + names = ( + names[0], + names[1], + "guard_before_shifted_counter", + names[3], + names[4], + names[5], + names[6], + ) + total_samples = int(sum(counts)) + waveform = np.zeros(total_samples, dtype=np.complex64) + sections = [] + start = 0 + for name, count, purpose in zip(names, counts, purposes, strict=True): + sections.append(_section(name, start, count, actual_sample_rate_hz, purpose)) + start += count + + by_name = {row["name"]: row for row in sections} + tone = by_name["tone_plus_50khz"] + tone_indexes = np.arange(tone["sample_count"], dtype=np.float64) + waveform[tone["start_sample"] : tone["end_sample_exclusive"]] = ( + counter.TX_AMPLITUDE + * np.exp(2j * np.pi * TONE_OFFSET_HZ * tone_indexes / actual_sample_rate_hz) + ).astype(np.complex64) + + shifted = by_name["counter_plus_50khz"] + shifted_indexes = np.arange(shifted["sample_count"], dtype=np.float64) + shifted_carrier = np.exp( + 2j * np.pi * TONE_OFFSET_HZ * shifted_indexes / actual_sample_rate_hz + ) + waveform[shifted["start_sample"] : shifted["end_sample_exclusive"]] = ( + counter_waveform * shifted_carrier + ).astype(np.complex64) + centered = by_name["counter_zero_if"] + waveform[centered["start_sample"] : centered["end_sample_exclusive"]] = ( + counter_waveform + ) + + symbol_ids = counter.counter_symbol_ids(0, counter.TRANSMITTED_SYMBOL_COUNT) + symbol_sha256 = hashlib.sha256(symbol_ids.tobytes()).hexdigest() + metadata = { + "sample_rate_hz": float(actual_sample_rate_hz), + "total_sample_count": total_samples, + "total_duration_seconds": float(total_samples / actual_sample_rate_hz), + "tone_offset_hz": TONE_OFFSET_HZ, + "tone_amplitude": counter.TX_AMPLITUDE, + "counter_amplitude": counter.TX_AMPLITUDE, + "counter_symbol_ids_sha256_shifted": symbol_sha256, + "counter_symbol_ids_sha256_centered": symbol_sha256, + "digital_sequences_bit_identical": True, + "sections": sections, + "counter": counter_parameters + | { + "lfsr_polynomial": counter.LFSR_POLYNOMIAL, + "lfsr_initial_state_hex": f"0x{counter.LFSR_INITIAL_STATE:05X}", + "pulse_shape": "rectangular", + }, + "peak_amplitude": float(np.max(np.abs(waveform))), + } + return waveform, metadata + + +def scale_pluto_samples_in_place(waveform: np.ndarray) -> np.ndarray: + """Преобразовать нормированный complex64-буфер в шкалу Pluto без копии.""" + + samples = np.asarray(waveform) + if samples.dtype != np.dtype(np.complex64) or samples.ndim != 1 or not len(samples): + raise ValueError("TX-буфер должен быть непустым одномерным complex64") + if not np.all(np.isfinite(samples)) or float(np.max(np.abs(samples))) > 1.0: + raise ValueError("Нормированный TX-буфер недействителен") + np.multiply(samples, np.float32(2**14), out=samples) + return samples + + +def validate_pluto_working_point(parameters: dict) -> None: + """Проверить все заданные параметры Pluto+ до единственного TX.""" + + counter.validate_pluto_working_point(parameters) + if parameters.get("actual_rf_bandwidth_hz") != 200_000: + raise RuntimeError( + "Рабочая точка Pluto+ не подтверждена: фактическая RF bandwidth не 200 кГц" + ) + + +def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while block := stream.read(4 * 1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def _write_json_atomically(path: Path, document: dict) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def verify_int8_to_complex64( + int8_path: Path, + iq_path: Path, + chunk_samples: int = 1_000_000, +) -> dict: + """Проверить signed int8, порядок I/Q и точное преобразование по частям.""" + + raw = np.memmap(int8_path, dtype=np.int8, mode="r") + iq = np.load(iq_path, mmap_mode="r", allow_pickle=False) + if len(raw) != 2 * len(iq) or len(iq) == 0 or iq.dtype != np.dtype(np.complex64): + raise RuntimeError("Размеры int8 и complex64 raw не согласованы") + for start in range(0, len(iq), chunk_samples): + end = min(len(iq), start + chunk_samples) + codes = np.asarray(raw[2 * start : 2 * end]).reshape(-1, 2).astype(np.float32) + restored = ((codes[:, 0] + 1j * codes[:, 1]) / 128.0).astype(np.complex64) + if not np.array_equal(restored, np.asarray(iq[start:end])): + raise RuntimeError(f"int8 → complex64 не совпал начиная с отсчёта {start}") + return { + "verified": True, + "int8_dtype": "signed int8", + "byte_order": "interleaved I then Q", + "scale_divisor": 128.0, + "complex_dtype": "complex64", + "sample_count": int(len(iq)), + } + + +def save_dual_raw_before_processing( + base_path: Path, + raw_bytes: bytes, + samples: np.ndarray, + metadata: dict, +) -> tuple[Path, Path, Path, dict]: + """Сохранить int8 и complex64, перечитать и проверить их до анализа.""" + + iq = np.asarray(samples) + if not isinstance(raw_bytes, bytes) or len(raw_bytes) != 2 * len(iq): + raise ValueError("Исходный int8 raw не соответствует числу complex64-отсчётов") + if iq.dtype != np.dtype(np.complex64) or iq.ndim != 1 or not np.all(np.isfinite(iq)): + raise ValueError("Complex64 raw недействителен") + if metadata.get("capture_status") != "captured": + raise ValueError("До обработки capture_status должен быть captured") + if metadata.get("processing_status") != "pending": + raise ValueError("До обработки processing_status должен быть pending") + + base = Path(base_path) + base.parent.mkdir(parents=True, exist_ok=True) + int8_path = base.with_suffix(".int8") + iq_path = base.with_suffix(".npy") + metadata_path = base.with_suffix(".json") + int8_path.write_bytes(raw_bytes) + np.save(iq_path, iq, allow_pickle=False) + document = dict(metadata) + document.update( + { + "int8_file": int8_path.name, + "int8_byte_count": len(raw_bytes), + "int8_sha256": None, + "iq_file": iq_path.name, + "sample_count": int(len(iq)), + "dtype": "complex64", + "iq_sha256": None, + "raw_verified": False, + "int8_to_complex64": None, + } + ) + _write_json_atomically(metadata_path, document) + initial_int8_sha = hashlib.sha256(raw_bytes).hexdigest() + int8_sha = _hash_file(int8_path) + iq_sha = _hash_file(iq_path) + if int8_sha != initial_int8_sha or _hash_file(int8_path) != int8_sha: + raise RuntimeError("Повторная SHA-256 проверка int8 raw не прошла") + if _hash_file(iq_path) != iq_sha: + raise RuntimeError("Повторная SHA-256 проверка complex64 raw не прошла") + conversion = verify_int8_to_complex64(int8_path, iq_path) + document.update( + { + "int8_sha256": int8_sha, + "iq_sha256": iq_sha, + "raw_verified": True, + "int8_to_complex64": conversion, + } + ) + _write_json_atomically(metadata_path, document) + return int8_path, iq_path, metadata_path, document + + +def _code_distribution(codes: np.ndarray) -> dict[str, int]: + values, counts = np.unique(np.asarray(codes, dtype=np.int8), return_counts=True) + return {str(int(value)): int(count) for value, count in zip(values, counts, strict=True)} + + +def background_metrics(samples: np.ndarray, codes: np.ndarray, sample_rate_hz: float) -> dict: + """Измерить фон без вычитания постоянного комплексного смещения.""" + + values = np.asarray(samples, dtype=np.complex64) + pairs = np.asarray(codes, dtype=np.int8) + if values.ndim != 1 or pairs.shape != (len(values), 2) or not len(values): + raise ValueError("Фоновый int8/complex64 интервал недействителен") + frequencies, density = welch( + values, + fs=sample_rate_hz, + window="hann", + nperseg=min(65_536, len(values)), + noverlap=0, + return_onesided=False, + scaling="density", + ) + finite_density = density[np.isfinite(density) & (density > 0.0)] + mean_value = complex(np.mean(values, dtype=np.complex128)) + mean_power = float(np.mean(np.abs(values.astype(np.complex128)) ** 2)) + return { + "sample_count": int(len(values)), + "i_code_distribution": _code_distribution(pairs[:, 0]), + "q_code_distribution": _code_distribution(pairs[:, 1]), + "mean_i": float(mean_value.real), + "mean_q": float(mean_value.imag), + "complex_dc": {"i": float(mean_value.real), "q": float(mean_value.imag)}, + "rms": float(math.sqrt(mean_power)), + "mean_power": mean_power, + "median_noise_psd_dbfs_per_hz": float( + 10.0 * math.log10(max(float(np.median(finite_density)), 1e-300)) + ), + "welch_bin_hz": float(sample_rate_hz / min(65_536, len(values))), + "dc_correction_applied": False, + } + + +def _parabolic_frequency(frequencies: np.ndarray, powers: np.ndarray, peak: int) -> float: + if peak <= 0 or peak >= len(powers) - 1: + return float(frequencies[peak]) + y0, y1, y2 = np.log(np.maximum(powers[peak - 1 : peak + 2], 1e-300)) + denominator = y0 - 2.0 * y1 + y2 + fraction = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator + return float(frequencies[peak] + fraction * (frequencies[1] - frequencies[0])) + + +def locate_and_measure_tone( + samples: np.ndarray, + sample_rate_hz: float, + expected_start_sample: int, + expected_count: int, +) -> dict: + """Найти фактический тон около +50 кГц в априорном временном окне.""" + + search_margin = int(round(0.35 * sample_rate_hz)) + search_start = max(0, expected_start_sample - search_margin) + search_end = min(len(samples), expected_start_sample + expected_count + search_margin) + search = np.asarray(samples[search_start:search_end], dtype=np.complex64) + fft_samples = min(524_288, 1 << (len(search).bit_length() - 1)) + if fft_samples < 65_536: + raise RuntimeError("Недостаточно raw для поиска контрольного тона") + hop = max(fft_samples // 8, 1) + window = np.hanning(fft_samples).astype(np.float32) + frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate_hz) + tone_band = np.flatnonzero( + np.abs(frequencies - TONE_OFFSET_HZ) <= TONE_SEARCH_HALF_WIDTH_HZ + ) + best_candidate: tuple[float, int, int, np.ndarray] | None = None + for start in range(0, len(search) - fft_samples + 1, hop): + power = np.abs(np.fft.fft(search[start : start + fft_samples] * window)) ** 2 + peak = int(tone_band[np.argmax(power[tone_band])]) + candidate = (float(power[peak]), start, peak, power) + if best_candidate is None or candidate[0] > best_candidate[0]: + best_candidate = candidate + if best_candidate is None: + raise RuntimeError("В априорном окне нет полного FFT-блока контрольного тона") + _, coarse_start, coarse_peak, coarse_power = best_candidate + frequency_hz = _parabolic_frequency(frequencies, coarse_power, coarse_peak) + detector_samples = 8_192 + detector_hop = 2_048 + detector_scores = [] + detector_starts = [] + detector_indexes = np.arange(detector_samples, dtype=np.float64) + detector_oscillator = np.exp( + -2j * np.pi * frequency_hz * detector_indexes / sample_rate_hz + ) + for start in range(0, len(search) - detector_samples + 1, detector_hop): + gain = np.mean( + search[start : start + detector_samples].astype(np.complex128) + * detector_oscillator + * np.exp(-2j * np.pi * frequency_hz * start / sample_rate_hz) + ) + detector_starts.append(start) + detector_scores.append(float(abs(gain) ** 2)) + scores = np.asarray(detector_scores, dtype=np.float64) + baseline = float(np.percentile(scores, 20.0)) + threshold = baseline + 0.35 * (float(np.max(scores)) - baseline) + active = scores >= threshold + best_run: tuple[int, int] | None = None + run_start = None + for index, is_active in enumerate(np.append(active, False)): + if is_active and run_start is None: + run_start = index + elif not is_active and run_start is not None: + if best_run is None or index - run_start > best_run[1] - best_run[0]: + best_run = (run_start, index) + run_start = None + if best_run is None: + raise RuntimeError("Не удалось выделить временной интервал контрольного тона") + detected_start = search_start + detector_starts[best_run[0]] + detected_end = ( + search_start + + detector_starts[best_run[1] - 1] + + detector_samples + ) + center_sample = (detected_start + detected_end) // 2 + tone_start = int(round(center_sample - expected_count / 2.0)) + tone_start = int(np.clip(tone_start, 0, len(samples) - expected_count)) + tone_end = tone_start + expected_count + tone = np.asarray(samples[tone_start:tone_end], dtype=np.complex64) + indexes = np.arange(expected_count, dtype=np.float64) + oscillator = np.exp(-2j * np.pi * frequency_hz * indexes / sample_rate_hz) + gain = complex(np.mean(tone.astype(np.complex128) * oscillator)) + predicted = gain * np.conj(oscillator) + residual_power = float(np.mean(np.abs(tone.astype(np.complex128) - predicted) ** 2)) + spectrum = np.abs(np.fft.fft(tone[:fft_samples] * window)) ** 2 + local = ( + np.abs(frequencies - frequency_hz) <= TONE_SEARCH_HALF_WIDTH_HZ + ) & (np.abs(frequencies - frequency_hz) >= TONE_LOCAL_NOISE_GUARD_HZ) + local_noise = float(np.median(spectrum[local])) + peak_power = float(spectrum[int(np.argmin(np.abs(frequencies - frequency_hz)))]) + + harmonics = {} + for order in (2, 3): + harmonic_frequency = order * frequency_hz + if abs(harmonic_frequency) >= sample_rate_hz / 2.0: + continue + harmonic = complex( + np.mean( + tone.astype(np.complex128) + * np.exp(-2j * np.pi * harmonic_frequency * indexes / sample_rate_hz) + ) + ) + harmonics[str(order)] = { + "frequency_hz": float(harmonic_frequency), + "amplitude": float(abs(harmonic)), + "relative_to_tone_db": float( + 20.0 * math.log10(max(abs(harmonic), 1e-300) / max(abs(gain), 1e-300)) + ), + } + component = np.maximum(np.abs(tone.real), np.abs(tone.imag)) + dc = complex(np.mean(tone, dtype=np.complex128)) + return { + "start_sample": tone_start, + "end_sample_exclusive": tone_end, + "frequency_hz": frequency_hz, + "frequency_offset_from_requested_hz": float(frequency_hz - TONE_OFFSET_HZ), + "amplitude": float(abs(gain)), + "power": float(abs(gain) ** 2), + "residual_power_without_dc_correction": residual_power, + "tone_to_residual_db": float( + 10.0 * math.log10(max(abs(gain) ** 2, 1e-300) / max(residual_power, 1e-300)) + ), + "peak_above_local_noise_db": float( + 10.0 * math.log10(max(peak_power, 1e-300) / max(local_noise, 1e-300)) + ), + "complex_dc": {"i": float(dc.real), "q": float(dc.imag)}, + "harmonics": harmonics, + "maximum_component": float(np.max(component)), + "clipped_component_fraction": float(np.mean(component >= 127.0 / 128.0)), + "dc_correction_applied": False, + } + + +def _mix_samples( + samples: np.ndarray, + absolute_start_sample: int, + frequency_hz: float, + sample_rate_hz: float, +) -> np.ndarray: + values = np.asarray(samples, dtype=np.complex64) + result = np.empty(len(values), dtype=np.complex64) + chunk = 1_000_000 + for start in range(0, len(values), chunk): + end = min(len(values), start + chunk) + indexes = absolute_start_sample + np.arange(start, end, dtype=np.float64) + result[start:end] = values[start:end] * np.exp( + -2j * np.pi * frequency_hz * indexes / sample_rate_hz + ) + return result + + +def _integrate_symbols( + samples: np.ndarray, + start_sample: int, + symbol_count: int, + carrier_hz: float, + sample_rate_hz: float, +) -> np.ndarray: + sample_count = symbol_count * counter.NOMINAL_SAMPLES_PER_SYMBOL + mixed = _mix_samples( + samples[start_sample : start_sample + sample_count], + start_sample, + carrier_hz, + sample_rate_hz, + ) + symbols = mixed.reshape(symbol_count, counter.NOMINAL_SAMPLES_PER_SYMBOL).mean(axis=1) + return symbols.astype(np.complex64) + + +def _find_counter_start( + samples: np.ndarray, + expected_start_sample: int, + carrier_hz: float, + sample_rate_hz: float, +) -> int: + margin = int(round(COARSE_SEARCH_SECONDS * sample_rate_hz)) + region_start = max(0, expected_start_sample - margin) + region_end = min( + len(samples), + expected_start_sample + + counter.TRANSMITTED_SYMBOL_COUNT * counter.NOMINAL_SAMPLES_PER_SYMBOL + + margin, + ) + region_count = (region_end - region_start) // counter.NOMINAL_SAMPLES_PER_SYMBOL + mixed = _mix_samples( + samples[ + region_start : region_start + + region_count * counter.NOMINAL_SAMPLES_PER_SYMBOL + ], + region_start, + carrier_hz, + sample_rate_hz, + ) + coarse_symbols = mixed.reshape( + region_count, counter.NOMINAL_SAMPLES_PER_SYMBOL + ).mean(axis=1) + del mixed + reference = counter.counter_qpsk_symbols( + MATCH_REFERENCE_START_SYMBOL, + MATCH_REFERENCE_SYMBOL_COUNT, + ).astype(np.complex128) + correlation = fftconvolve( + coarse_symbols.astype(np.complex128), + np.conj(reference[::-1]), + mode="valid", + ) + match_index = int(np.argmax(np.abs(correlation))) + coarse_start = ( + region_start + + (match_index - MATCH_REFERENCE_START_SYMBOL) + * counter.NOMINAL_SAMPLES_PER_SYMBOL + ) + candidates = np.arange( + coarse_start - counter.NOMINAL_SAMPLES_PER_SYMBOL + 1, + coarse_start + counter.NOMINAL_SAMPLES_PER_SYMBOL, + dtype=np.int64, + ) + subset_starts = ( + candidates + + MATCH_REFERENCE_START_SYMBOL * counter.NOMINAL_SAMPLES_PER_SYMBOL + ) + valid = (subset_starts >= 0) & ( + subset_starts + + MATCH_REFERENCE_SYMBOL_COUNT * counter.NOMINAL_SAMPLES_PER_SYMBOL + <= len(samples) + ) + candidates = candidates[valid] + subset_starts = subset_starts[valid] + if not len(candidates): + raise RuntimeError("Не удалось уточнить начало цифрового участка") + mixed_start = int(np.min(subset_starts)) + mixed_end = int( + np.max(subset_starts) + + MATCH_REFERENCE_SYMBOL_COUNT * counter.NOMINAL_SAMPLES_PER_SYMBOL + ) + mixed = _mix_samples( + samples[mixed_start:mixed_end], + mixed_start, + carrier_hz, + sample_rate_hz, + ) + cumulative = np.concatenate( + (np.zeros(1, dtype=np.complex128), np.cumsum(mixed, dtype=np.complex128)) + ) + symbol_offsets = ( + np.arange(MATCH_REFERENCE_SYMBOL_COUNT, dtype=np.int64) + * counter.NOMINAL_SAMPLES_PER_SYMBOL + ) + best: tuple[float, int] | None = None + for candidate, subset_start in zip(candidates, subset_starts, strict=True): + starts = int(subset_start - mixed_start) + symbol_offsets + symbols = ( + cumulative[starts + counter.NOMINAL_SAMPLES_PER_SYMBOL] + - cumulative[starts] + ) / counter.NOMINAL_SAMPLES_PER_SYMBOL + gain = float(abs(np.mean(symbols * np.conj(reference)))) + row = (gain, -int(candidate)) + if best is None or row > best: + best = row + if best is None: + raise RuntimeError("Не удалось уточнить начало цифрового участка") + return -best[1] + + +def _constellation_metrics(symbols: np.ndarray, expected_ids: np.ndarray) -> dict: + centroids = [] + residual_sum = 0.0 + for state in range(4): + group = symbols[expected_ids == state] + centroid = complex(np.mean(group, dtype=np.complex128)) + centroids.append(centroid) + residual_sum += float(np.sum(np.abs(group - centroid) ** 2)) + distances = [ + abs(centroids[left] - centroids[right]) + for left in range(4) + for right in range(left + 1, 4) + ] + within_rms = math.sqrt(residual_sum / len(symbols)) + minimum_distance = float(min(distances)) + return { + "centroids": [ + {"i": float(value.real), "q": float(value.imag)} for value in centroids + ], + "minimum_centroid_distance": minimum_distance, + "pooled_within_state_rms": float(within_rms), + "centroid_separation_to_within_rms": float( + minimum_distance / max(within_rms, 1e-300) + ), + } + + +def analyze_counter_section( + samples: np.ndarray, + expected_start_sample: int, + prior_carrier_hz: float, + sample_rate_hz: float, + background_power: float, +) -> dict: + """Одинаково обработать один известный цифровой участок без DC-коррекции.""" + + start = _find_counter_start( + samples, + expected_start_sample, + prior_carrier_hz, + sample_rate_hz, + ) + end = start + counter.TRANSMITTED_SYMBOL_COUNT * counter.NOMINAL_SAMPLES_PER_SYMBOL + if start < 0 or end > len(samples): + raise RuntimeError("Полный цифровой участок не помещается в raw") + symbols = _integrate_symbols( + samples, + start, + counter.TRANSMITTED_SYMBOL_COUNT, + prior_carrier_hz, + sample_rate_hz, + ).astype(np.complex128) + expected_ids = counter.counter_symbol_ids(0, counter.TRANSMITTED_SYMBOL_COUNT) + expected = counter.QPSK_STATES[expected_ids].astype(np.complex128) + matched = symbols * np.conj(expected) + symbol_rate = sample_rate_hz / counter.NOMINAL_SAMPLES_PER_SYMBOL + time_axis = (np.arange(len(symbols), dtype=np.float64) + 0.5) / symbol_rate + power = np.abs(np.fft.fft(matched * np.hanning(len(matched)))) ** 2 + frequencies = np.fft.fftfreq(len(matched), d=1.0 / symbol_rate) + peak = int(np.argmax(power)) + residual_initial = _parabolic_frequency(frequencies, power, peak) + block_count = len(matched) // CFO_BLOCK_SYMBOLS + initial = matched * np.exp(-2j * np.pi * residual_initial * time_axis) + block_products = initial[: block_count * CFO_BLOCK_SYMBOLS].reshape( + block_count, CFO_BLOCK_SYMBOLS + ).mean(axis=1) + block_times = time_axis[: block_count * CFO_BLOCK_SYMBOLS].reshape( + block_count, CFO_BLOCK_SYMBOLS + ).mean(axis=1) + slope, _ = np.polyfit(block_times, np.unwrap(np.angle(block_products)), 1) + residual_cfo = float(residual_initial + slope / (2.0 * np.pi)) + derotation = np.exp(-2j * np.pi * residual_cfo * time_axis) + gain = complex(np.mean(matched * derotation)) + predicted = gain * expected * np.exp(2j * np.pi * residual_cfo * time_axis) + residual_power = float(np.mean(np.abs(symbols - predicted) ** 2)) + signal_power = float(abs(gain) ** 2) + normalized = symbols * derotation / gain + received_ids = np.argmin( + np.abs(normalized[:, None] - counter.QPSK_STATES.astype(np.complex128)[None, :]), + axis=1, + ).astype(np.uint8) + symbol_errors = int(np.count_nonzero(received_ids != expected_ids)) + position = counter.analyze_counter_symbols(received_ids) + section = np.asarray(samples[start:end], dtype=np.complex64) + dc = complex(np.mean(section, dtype=np.complex128)) + total_power = float(np.mean(np.abs(section.astype(np.complex128)) ** 2)) + excess_power = total_power - background_power + corrected_symbols = normalized.astype(np.complex64) + return { + "start_sample": int(start), + "end_sample_exclusive": int(end), + "prior_carrier_hz": float(prior_carrier_hz), + "measured_carrier_hz": float(prior_carrier_hz + residual_cfo), + "residual_cfo_hz": residual_cfo, + "complex_gain": {"i": float(gain.real), "q": float(gain.imag)}, + "useful_signal_power_symbol": signal_power, + "noise_and_distortion_power_symbol": residual_power, + "symbol_snr_db": float( + 10.0 * math.log10(max(signal_power, 1e-300) / max(residual_power, 1e-300)) + ), + "section_total_power": total_power, + "background_power": float(background_power), + "sample_excess_power": float(excess_power) if excess_power > 0.0 else None, + "sample_power_snr_db": ( + float(10.0 * math.log10(excess_power / background_power)) + if excess_power > 0.0 + else None + ), + "sample_power_snr_status": "computed" if excess_power > 0.0 else "not_computed", + "complex_dc": {"i": float(dc.real), "q": float(dc.imag)}, + "constellation": _constellation_metrics(corrected_symbols, expected_ids), + "symbol_error_count": symbol_errors, + "symbol_error_rate": float(symbol_errors / len(expected_ids)), + "recovered_position_count": int(position["recovered_symbol_count"]), + "anchor_count": int(position["anchor_count"]), + "position_map_available": bool(position["recovered_symbol_count"] > 0), + "position_analysis": position, + "dc_correction_applied": False, + "new_filter_applied": False, + "equalizer_applied": False, + } + + +def _mapped_section_bounds(section: dict, raw_tx_start: int, tx_fs: float, rx_fs: float) -> tuple[int, int]: + start = raw_tx_start + int(round(section["start_sample"] / tx_fs * rx_fs)) + end = raw_tx_start + int(round(section["end_sample_exclusive"] / tx_fs * rx_fs)) + return start, end + + +def analyze_saved_capture( + samples: np.ndarray, + raw_codes: np.ndarray, + document: dict, +) -> dict: + """Выполнить весь анализ только после проверки обоих raw-файлов.""" + + rx_fs = float(document["rx_parameters"]["set_sample_rate_hz"]) + tx_fs = float(document["tx_parameters"]["actual_sample_rate_hz"]) + sections = {row["name"]: row for row in document["waveform"]["sections"]} + boundaries = document["callback_boundaries"] + stream_start = float(document["continuous_rx"]["stream_started_monotonic"]) + estimated_tx_return = ( + float(document["tx_timing"]["tx_completed_monotonic"]) + - float(document["tx_timing"]["tx_buffer_hold_seconds"]) + ) + relative = estimated_tx_return - stream_start + times = np.asarray([row["monotonic_seconds"] for row in boundaries], dtype=np.float64) + ends = np.asarray([row["accepted_end_sample"] for row in boundaries], dtype=np.float64) + expected_tx_start = int(round(np.interp(relative, times, ends))) + tone_section = sections["tone_plus_50khz"] + expected_tone_start, expected_tone_end = _mapped_section_bounds( + tone_section, expected_tx_start, tx_fs, rx_fs + ) + tone = locate_and_measure_tone( + samples, + rx_fs, + expected_tone_start, + expected_tone_end - expected_tone_start, + ) + raw_tx_start = tone["start_sample"] - int( + round(tone_section["start_sample"] / tx_fs * rx_fs) + ) + raw_tx_end = raw_tx_start + int( + round(document["waveform"]["total_sample_count"] / tx_fs * rx_fs) + ) + if raw_tx_start < 0 or raw_tx_end > len(samples): + raise RuntimeError("Полный составной TX-буфер не помещается в raw") + + quiet_margin = int(round(0.05 * rx_fs)) + quiet_count = int(round(0.35 * rx_fs)) + pre_start = max(0, raw_tx_start - quiet_margin - quiet_count) + pre_end = max(pre_start, raw_tx_start - quiet_margin) + post_start = min(len(samples), raw_tx_end + quiet_margin) + post_end = min(len(samples), post_start + quiet_count) + if pre_end <= pre_start or post_end <= post_start: + raise RuntimeError("Не сохранились оба фоновых участка до и после TX") + background_samples = np.concatenate( + (np.asarray(samples[pre_start:pre_end]), np.asarray(samples[post_start:post_end])) + ) + pairs = np.asarray(raw_codes).reshape(-1, 2) + background_codes = np.concatenate( + (np.asarray(pairs[pre_start:pre_end]), np.asarray(pairs[post_start:post_end])) + ) + background = background_metrics(background_samples, background_codes, rx_fs) + + shifted_expected, _ = _mapped_section_bounds( + sections["counter_plus_50khz"], raw_tx_start, tx_fs, rx_fs + ) + centered_expected, _ = _mapped_section_bounds( + sections["counter_zero_if"], raw_tx_start, tx_fs, rx_fs + ) + shifted = analyze_counter_section( + samples, + shifted_expected, + tone["frequency_hz"], + rx_fs, + background["mean_power"], + ) + centered = analyze_counter_section( + samples, + centered_expected, + tone["frequency_hz"] - TONE_OFFSET_HZ, + rx_fs, + background["mean_power"], + ) + snr_improvement = shifted["symbol_snr_db"] - centered["symbol_snr_db"] + ser_improvement = centered["symbol_error_rate"] - shifted["symbol_error_rate"] + if snr_improvement >= 3.0 and ser_improvement >= 0.05: + interpretation = "A" + conclusion = "Перенос на +50 кГц значительно улучшил приём относительно центра" + elif abs(snr_improvement) < 1.5 and max( + shifted["symbol_error_rate"], centered["symbol_error_rate"] + ) >= 0.25: + interpretation = "B" + conclusion = "Оба цифровых участка одинаково плохи; основной предел остаётся уровневым" + elif max(shifted["symbol_error_rate"], centered["symbol_error_rate"]) <= 0.02: + interpretation = "C" + conclusion = "Оба цифровых участка достоверно декодируются" + else: + interpretation = "D" + conclusion = "Результат прямого сравнения неоднозначен" + return { + "raw_tx_start_sample": int(raw_tx_start), + "raw_tx_end_sample_exclusive": int(raw_tx_end), + "raw_tx_start_seconds": float(raw_tx_start / rx_fs), + "raw_tx_end_seconds": float(raw_tx_end / rx_fs), + "background_intervals": [ + {"start_sample": pre_start, "end_sample_exclusive": pre_end}, + {"start_sample": post_start, "end_sample_exclusive": post_end}, + ], + "background": background, + "tone_plus_50khz": tone, + "counter_plus_50khz": shifted, + "counter_zero_if": centered, + "comparison": { + "symbol_snr_improvement_plus_50khz_db": float(snr_improvement), + "symbol_error_rate_improvement_plus_50khz": float(ser_improvement), + "interpretation": interpretation, + "conclusion": conclusion, + "zero_if_degradation_proven": interpretation == "A", + "insufficient_level_remains_primary": interpretation == "B", + }, + "usb_loss_analysis": "not_performed", + "dc_correction_applied": False, + "new_filter_applied": False, + "equalizer_applied": False, + } + + +def save_processed_artifacts(analysis: dict, output_directory: Path) -> tuple[Path, ...]: + output = Path(output_directory) + output.mkdir(parents=True, exist_ok=True) + _write_json_atomically(output / "analysis.json", analysis) + shifted = analysis["counter_plus_50khz"] + centered = analysis["counter_zero_if"] + rows = [ + ("мощность полезного сигнала на символ", shifted["useful_signal_power_symbol"], centered["useful_signal_power_symbol"]), + ("мощность шума и искажений на символ", shifted["noise_and_distortion_power_symbol"], centered["noise_and_distortion_power_symbol"]), + ("SNR на символ, дБ", shifted["symbol_snr_db"], centered["symbol_snr_db"]), + ("разделимость состояний", shifted["constellation"]["centroid_separation_to_within_rms"], centered["constellation"]["centroid_separation_to_within_rms"]), + ("доля ошибочных символов", shifted["symbol_error_rate"], centered["symbol_error_rate"]), + ("восстановленные позиции", shifted["recovered_position_count"], centered["recovered_position_count"]), + ("постоянное смещение I", shifted["complex_dc"]["i"], centered["complex_dc"]["i"]), + ("постоянное смещение Q", shifted["complex_dc"]["q"], centered["complex_dc"]["q"]), + ] + with (output / "comparison.csv").open("w", newline="", encoding="utf-8-sig") as stream: + writer = csv.writer(stream) + writer.writerow(("показатель", "+50 кГц", "около 0 Гц")) + writer.writerows(rows) + report = [ + "Lab043: прямое сравнение цифрового счётчика +50 кГц и около 0 Гц", + "", + f"Интерпретация: {analysis['comparison']['interpretation']}.", + analysis["comparison"]["conclusion"] + ".", + f"SNR на символ +50 кГц: {shifted['symbol_snr_db']:.3f} дБ.", + f"SNR на символ около 0 Гц: {centered['symbol_snr_db']:.3f} дБ.", + f"Доля ошибочных символов +50 кГц: {shifted['symbol_error_rate']:.6f}.", + f"Доля ошибочных символов около 0 Гц: {centered['symbol_error_rate']:.6f}.", + f"Восстановленные позиции +50 кГц: {shifted['recovered_position_count']}.", + f"Восстановленные позиции около 0 Гц: {centered['recovered_position_count']}.", + "", + "SNR означает отношение мощности сигнала к мощности шума и искажений.", + "I/Q означает синфазные и квадратурные комплексные отсчёты.", + "Постоянное смещение не вычиталось; эквалайзер и новый фильтр не применялись.", + ] + (output / "report.txt").write_text("\n".join(report) + "\n", encoding="utf-8") + return tuple(sorted(output.iterdir())) + + +def update_processing_metadata( + metadata_path: Path, + processing_status: str, + processing_error: str | None, + analysis: dict | None = None, +) -> dict: + if processing_status not in {"success", "processing_failed"}: + raise ValueError("Неизвестный processing_status") + document = json.loads(Path(metadata_path).read_text(encoding="utf-8")) + document["processing_status"] = processing_status + document["processing_error"] = processing_error + if analysis is not None: + document["analysis"] = analysis + _write_json_atomically(Path(metadata_path), document) + return document + + +def preflight_hardware() -> dict: + """Открыть оба SDR без TX и проверить единственную разрешённую точку.""" + + from experiments import lab043_hackrf_hardware as hackrf + from experiments import lab043_prbs11_hardware as pluto_hardware + + pluto_uri = pluto_hardware.discover_pluto_usb_uri() + return { + "pluto": pluto_hardware.preflight_pluto(pluto_uri), + "hackrf": counter.preflight_counter_receiver(hackrf), + } + + +def run_one_zero_if_comparison( + raw_directory: Path = RAW_DIRECTORY, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Выполнить ровно один разрешённый составной аппаратный опыт.""" + + from experiments import lab043_hackrf_hardware as hackrf + from experiments import lab043_prbs11_hardware as pluto_hardware + + pluto_uri = pluto_hardware.discover_pluto_usb_uri() + pluto_preflight = pluto_hardware.preflight_pluto(pluto_uri) + hackrf_preflight = counter.preflight_counter_receiver(hackrf) + pluto, tx_parameters = pluto_hardware.configure_pluto(pluto_uri) + validate_pluto_working_point(tx_parameters) + actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) + waveform, waveform_parameters = build_composite_waveform(actual_tx_rate) + waveform_sha256 = hashlib.sha256(waveform.tobytes()).hexdigest() + scale_pluto_samples_in_place(waveform) + target_samples = math.ceil( + ( + RX_LEADING_SECONDS + + waveform_parameters["total_duration_seconds"] + + RX_TRAILING_SECONDS + + TX_UPLOAD_MARGIN_SECONDS + ) + * hackrf.HACKRF_SAMPLE_RATE_HZ + ) + hackrf.HACKRF_EXPECTED_CALLBACK_BYTES // 2 + capture_started = datetime.now(timezone.utc) + capture_holder = [] + errors: list[str] = [] + tx_timing: dict[str, float] = {} + + with hackrf.HackrfRxDevice() as receiver: + rx_parameters = counter.configure_counter_receiver(receiver) + + def receive() -> None: + try: + capture_holder.append( + receiver.capture(target_samples, timeout_seconds=RX_TIMEOUT_SECONDS) + ) + except Exception as error: # pragma: no cover - аппаратный путь + errors.append(f"RX {type(error).__name__}: {error}") + + rx_thread = threading.Thread(target=receive, daemon=True) + rx_thread.start() + deadline = time.perf_counter() + 5.0 + stream_confirmed = False + while time.perf_counter() < deadline: + if errors: + raise RuntimeError( + "HackRF RX завершился ошибкой до TX; Pluto TX не включён: " + + "; ".join(errors) + ) + if receiver.library.hackrf_is_streaming(receiver.device) == 1: + stream_confirmed = True + break + time.sleep(0.01) + if not stream_confirmed: + raise RuntimeError("HackRF RX не подтвердил поток; Pluto TX не включён") + time.sleep(RX_LEADING_SECONDS) + if errors: + raise RuntimeError( + "HackRF RX завершился ошибкой до TX; Pluto TX не включён: " + + "; ".join(errors) + ) + if receiver.library.hackrf_is_streaming(receiver.device) != 1: + raise RuntimeError("HackRF RX остановился до TX; Pluto TX не включён") + tx_timing["tx_call_started_monotonic"] = time.perf_counter() + tx_timing["tx_buffer_hold_seconds"] = pluto_hardware.transmit_noncyclic_buffer_once( + pluto, + waveform, + actual_tx_rate, + ) + tx_timing["tx_completed_monotonic"] = time.perf_counter() + del waveform + gc.collect() + rx_thread.join(timeout=RX_TIMEOUT_SECONDS + 10.0) + if rx_thread.is_alive(): + raise RuntimeError("Единственный непрерывный HackRF RX-сеанс не завершился") + if errors: + raise RuntimeError("; ".join(errors)) + if len(capture_holder) != 1: + raise RuntimeError("HackRF не вернул единственный захват") + capture = capture_holder[0] + if ( + len(capture.samples) != target_samples + or len(capture.raw_bytes) != 2 * target_samples + or not np.all(np.isfinite(capture.samples)) + ): + raise RuntimeError("Размер или содержимое raw RX недействительны") + boundary_rows = [asdict(row) for row in capture.boundaries] + metadata = { + "experiment": "Lab043 HackRF zero-IF direct comparison", + "capture_status": "captured", + "processing_status": "pending", + "processing_error": None, + "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), + "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF ANTENNA", + "tx_parameters": tx_parameters, + "rx_parameters": rx_parameters + | { + "serial_number": hackrf_preflight["serial_number"], + "firmware_version": hackrf_preflight["firmware_version"], + "libhackrf_version": hackrf_preflight["libhackrf_version"], + }, + "pluto_preflight": pluto_preflight, + "hackrf_preflight": hackrf_preflight, + "waveform_sha256_normalized_complex64": waveform_sha256, + "waveform": waveform_parameters, + "tx_waveform_duration_seconds": waveform_parameters["total_duration_seconds"], + "tx_timing": tx_timing, + "callback_count": capture.diagnostics["callback_count_total"], + "callback_boundaries": boundary_rows, + "continuous_rx": capture.diagnostics | {"boundaries": boundary_rows}, + "expected_intervals": { + "rx_leading_seconds": RX_LEADING_SECONDS, + "rx_trailing_seconds": RX_TRAILING_SECONDS, + "tx_upload_margin_seconds": TX_UPLOAD_MARGIN_SECONDS, + }, + "forbidden_modes_executed": [], + "automatic_gain_change": False, + "dc_correction_applied": False, + } + stamp = capture_started.strftime("%Y%m%d_%H%M%S") + base = Path(raw_directory) / f"lab043_hackrf_zero_if_compare_{stamp}" + int8_path, iq_path, metadata_path, document = save_dual_raw_before_processing( + base, + capture.raw_bytes, + capture.samples, + metadata, + ) + del capture_holder, capture + gc.collect() + samples = np.load(iq_path, mmap_mode="r", allow_pickle=False) + raw_codes = np.memmap(int8_path, dtype=np.int8, mode="r") + if _hash_file(int8_path) != document["int8_sha256"]: + raise RuntimeError("SHA-256 int8 raw изменился до анализа") + if _hash_file(iq_path) != document["iq_sha256"]: + raise RuntimeError("SHA-256 complex64 raw изменился до анализа") + try: + analysis = analyze_saved_capture(samples, raw_codes, document) + save_processed_artifacts(analysis, processed_directory) + except Exception as error: + message = f"{type(error).__name__}: {error}" + update_processing_metadata(metadata_path, "processing_failed", message) + return { + "int8_path": str(int8_path), + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "int8_sha256": document["int8_sha256"], + "iq_sha256": document["iq_sha256"], + "processing_status": "processing_failed", + "processing_error": message, + } + update_processing_metadata(metadata_path, "success", None, analysis) + return { + "int8_path": str(int8_path), + "iq_path": str(iq_path), + "metadata_path": str(metadata_path), + "int8_sha256": document["int8_sha256"], + "iq_sha256": document["iq_sha256"], + "processing_status": "success", + "tx_parameters": tx_parameters, + "rx_parameters": metadata["rx_parameters"], + "analysis": analysis, + } + + +def reprocess_saved_capture( + metadata_path: Path, + processed_directory: Path = PROCESSED_DIRECTORY, +) -> dict: + """Повторить только анализ сохранённых raw без открытия SDR и без TX.""" + + path = Path(metadata_path) + document = json.loads(path.read_text(encoding="utf-8")) + int8_path = path.parent / document["int8_file"] + iq_path = path.parent / document["iq_file"] + if not document.get("raw_verified"): + raise RuntimeError("Raw не отмечены как проверенные") + if _hash_file(int8_path) != document.get("int8_sha256"): + raise RuntimeError("SHA-256 int8 raw не совпал") + if _hash_file(iq_path) != document.get("iq_sha256"): + raise RuntimeError("SHA-256 complex64 raw не совпал") + verify_int8_to_complex64(int8_path, iq_path) + samples = np.load(iq_path, mmap_mode="r", allow_pickle=False) + raw_codes = np.memmap(int8_path, dtype=np.int8, mode="r") + try: + analysis = analyze_saved_capture(samples, raw_codes, document) + save_processed_artifacts(analysis, processed_directory) + except Exception as error: + message = f"{type(error).__name__}: {error}" + update_processing_metadata(path, "processing_failed", message) + return {"processing_status": "processing_failed", "processing_error": message} + update_processing_metadata(path, "success", None, analysis) + return {"processing_status": "success", "analysis": analysis} + + +def main() -> int: + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--preflight-only", action="store_true") + group.add_argument("--run-one-zero-if-compare", action="store_true") + group.add_argument("--reprocess-metadata", type=Path) + arguments = parser.parse_args() + if arguments.preflight_only: + print(json.dumps(preflight_hardware(), ensure_ascii=False, indent=2)) + return 0 + result = ( + reprocess_saved_capture(arguments.reprocess_metadata) + if arguments.reprocess_metadata is not None + else run_one_zero_if_comparison() + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["processing_status"] == "success" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab043_pluto_to_rtlsdr.py b/experiments/lab043_pluto_to_rtlsdr.py index 6e7d0af..3213f37 100644 --- a/experiments/lab043_pluto_to_rtlsdr.py +++ b/experiments/lab043_pluto_to_rtlsdr.py @@ -650,6 +650,60 @@ def locate_calibration_tone_start( raise RuntimeError("Начало двухтоновой калибровки не найдено по фиксированному порогу 10 дБ") +def locate_calibration_tone_start_spectral( + samples: np.ndarray, + sample_rate_hz: float, + block_samples: int = CALIBRATION_NFFT, + hop_samples: int = CALIBRATION_NFFT // 4, + required_consecutive_blocks: int = 3, +) -> int: + """Найти начало по двум известным тонам с тем же порогом 10 дБ. + + Резервный метод нужен, когда узкие тоны надёжны спектрально, но не дают + десятикратного роста полной широкополосной мощности. Дополнительно + применяется существующее ограничение геометрии тонов: ошибка такта не + должна превышать 1000 ppm. + """ + + received = np.asarray(samples, dtype=np.complex128) + if received.ndim != 1 or len(received) < block_samples: + raise ValueError("Захват слишком короток для спектрального поиска калибровки") + if ( + sample_rate_hz <= 0.0 + or block_samples < 1024 + or hop_samples <= 0 + or required_consecutive_blocks <= 0 + ): + raise ValueError("Параметры спектрального поиска должны быть положительными") + window = np.hanning(block_samples) + frequencies = np.fft.fftshift( + np.fft.fftfreq(block_samples, d=1.0 / sample_rate_hz) + ) + starts = np.arange( + 0, + len(received) - block_samples + 1, + hop_samples, + dtype=np.int64, + ) + active = np.zeros(len(starts), dtype=bool) + for index, start in enumerate(starts): + block = received[start : start + block_samples] + centered = block - np.mean(block) + spectrum = np.fft.fftshift(np.fft.fft(centered * window)) + estimate = estimate_calibration_from_spectrum( + frequencies, + np.abs(spectrum) ** 2, + ) + active[index] = bool(estimate.valid) + for index in range(len(active) - required_consecutive_blocks + 1): + if bool(np.all(active[index : index + required_consecutive_blocks])): + return int(starts[index]) + raise RuntimeError( + "Начало двухтоновой калибровки не найдено спектрально: " + "два тона не прошли неизменный порог 10 дБ и проверку геометрии" + ) + + def split_calibration_and_bpsk( samples: np.ndarray, plan: PrbsTransmissionPlan, @@ -659,7 +713,15 @@ def split_calibration_and_bpsk( """Выделить тоны и BPSK по одной временной шкале одного захвата.""" received = np.asarray(samples, dtype=np.complex128) - tone_start = locate_calibration_tone_start(received, actual_rx_sample_rate_hz) + try: + tone_start = locate_calibration_tone_start(received, actual_rx_sample_rate_hz) + tone_start_locator = "broadband_power_10db" + except RuntimeError: + tone_start = locate_calibration_tone_start_spectral( + received, + actual_rx_sample_rate_hz, + ) + tone_start_locator = "two_tone_spectral_10db" tone_duration_rx = round( plan.calibration_sample_count / actual_tx_sample_rate_hz * actual_rx_sample_rate_hz ) @@ -684,6 +746,7 @@ def split_calibration_and_bpsk( received[bpsk_start:bpsk_end], { "tone_start_sample": int(tone_start), + "tone_start_locator": tone_start_locator, "calibration_start_sample": int(calibration_start), "calibration_end_sample": int(calibration_end), "bpsk_nominal_start_sample": int(bpsk_nominal_start), diff --git a/experiments/lab043_prbs11_hardware.py b/experiments/lab043_prbs11_hardware.py index eceec2d..722e4b7 100644 --- a/experiments/lab043_prbs11_hardware.py +++ b/experiments/lab043_prbs11_hardware.py @@ -41,12 +41,31 @@ class CapturedPrbsAnalysis: modes: dict[str, lab043.PrbsModeMetrics] -def preflight_pluto() -> dict: +def discover_pluto_usb_uri() -> str: + """Найти единственный Pluto+ через прямой USB-IIO без изменения сети.""" + + import iio + + contexts = iio.scan_contexts() + candidates = sorted( + uri + for uri, description in contexts.items() + if uri.startswith("usb:") and "PlutoSDR" in description + ) + if len(candidates) != 1: + raise RuntimeError( + "Ожидался ровно один USB-IIO контекст PlutoSDR, " + f"получено {len(candidates)}: {candidates}" + ) + return candidates[0] + + +def preflight_pluto(uri: str = PLUTO_URI) -> dict: """Подтвердить IIO-контекст, PHY и первый TX-канал без передачи.""" import iio - context = iio.Context(PLUTO_URI) + context = iio.Context(uri) phy = context.find_device("ad9361-phy") tx_device = context.find_device("cf-ad9361-dds-core-lpc") tx_channel = tx_device.find_channel("voltage0", True) if tx_device is not None else None @@ -55,7 +74,7 @@ def preflight_pluto() -> dict: if tx_device is None or tx_channel is None: raise RuntimeError("IIO-контекст открыт, но первый TX-канал недоступен") return { - "uri": PLUTO_URI, + "uri": uri, "context_opened": True, "phy_name": str(phy.name), "tx_device_name": str(tx_device.name), @@ -100,16 +119,17 @@ def preflight_rtlsdr() -> dict: sdr.close() -def configure_pluto() -> tuple[object, dict]: +def configure_pluto(uri: str = PLUTO_URI) -> tuple[object, dict]: import adi - device = adi.Pluto(uri=PLUTO_URI) + device = adi.Pluto(uri=uri) device.sample_rate = int(lab043.SAMPLE_RATE_HZ) device.tx_lo = int(lab043.CARRIER_HZ) device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ) device.tx_hardwaregain_chan0 = float(TX_GAIN_DB) device.tx_cyclic_buffer = False return device, { + "uri": uri, "requested_center_frequency_hz": int(lab043.CARRIER_HZ), "actual_center_frequency_hz": int(device.tx_lo), "requested_sample_rate_hz": int(lab043.SAMPLE_RATE_HZ), diff --git a/tests/test_lab043_bpsk_asymmetry_diagnostics.py b/tests/test_lab043_bpsk_asymmetry_diagnostics.py new file mode 100644 index 0000000..a027f4f --- /dev/null +++ b/tests/test_lab043_bpsk_asymmetry_diagnostics.py @@ -0,0 +1,78 @@ +import numpy as np + +from experiments import lab043_bpsk_asymmetry_diagnostics as diagnostics + + +def test_known_sequence_phase_fit_removes_known_linear_phase() -> None: + bits = np.tile(np.array([0, 0, 1, 1, 0, 1, 0, 1], dtype=np.uint8), 128) + expected = 2.0 * bits.astype(np.float64) - 1.0 + indexes = np.arange(len(expected), dtype=np.float64) + frequency_hz = 0.75 + received = expected * np.exp( + 1j * (0.37 + 2.0 * np.pi * frequency_hz * indexes / 20_000.0) + ) + + corrected, estimated_hz, fit_rmse = diagnostics._known_sequence_phase_fit( + received, expected, 20_000.0 + ) + normalized, _ = diagnostics._normalize_symbols(corrected, expected) + + assert abs(estimated_hz - frequency_hz) < 1e-9 + assert fit_rmse < 1e-12 + assert np.max(np.abs(normalized - expected)) < 1e-12 + + +def test_triplet_rows_count_and_classify_only_actual_decision_crossings() -> None: + bits = np.tile(np.array([0, 0, 0, 1, 1, 1, 0, 1], dtype=np.uint8), 20) + values = 2.0 * bits.astype(np.float64) - 1.0 + target = np.flatnonzero( + (bits[:-2] == 0) & (bits[1:-1] == 0) & (bits[2:] == 0) + )[0] + 1 + values[target] = 0.1 + + rows = diagnostics.triplet_rows("synthetic", bits, values.astype(np.complex128)) + by_triplet = {row["triplet"]: row for row in rows} + + assert sum(row["count"] for row in rows) == len(bits) - 2 + assert by_triplet["000"]["bit_errors"] == 1 + assert sum(row["bit_errors"] for row in rows) == 1 + assert by_triplet["000"]["decision_cross_fraction"] == by_triplet["000"]["ber"] + + +def test_rrc_audit_has_symmetric_unit_energy_pair() -> None: + impulse_rows, audit = diagnostics.rrc_rows() + + assert audit["tx_tap_count"] == 1201 + assert audit["rx_tap_count"] == 1201 + assert audit["symmetry_max_abs_error"] == 0.0 + assert abs(audit["tap_energy"] - 1.0) < 1e-12 + by_offset = {row["symbol_offset"]: row["h_normalized"] for row in impulse_rows} + assert by_offset[0] == 1.0 + for offset in range(1, 6): + assert abs(by_offset[offset] - by_offset[-offset]) < 1e-15 + + +def test_fractional_delay_preserves_order_and_length() -> None: + source = np.array([0.0, 1.0, 2.0, 3.0], dtype=np.complex128) + + shifted = diagnostics._fractional_delay(source, 0.5) + + assert len(shifted) == len(source) + assert np.allclose(shifted, [0.0, 0.5, 1.5, 2.5]) + + +def test_triplet_metrics_keep_complex_dc_visible() -> None: + bits = np.tile(np.array([0, 0, 1, 1, 0, 1, 0, 1], dtype=np.uint8), 20) + expected = 2.0 * bits.astype(np.float64) - 1.0 + offset = 0.2 - 0.1j + + rows = diagnostics.triplet_rows( + "affine", bits, expected.astype(np.complex128) + offset + ) + + negative = [row for row in rows if row["triplet"][1] == "0" and row["count"]] + positive = [row for row in rows if row["triplet"][1] == "1" and row["count"]] + present = [row for row in rows if row["count"]] + assert all(abs(row["mean_i"] + 0.8) < 1e-12 for row in negative) + assert all(abs(row["mean_i"] - 1.2) < 1e-12 for row in positive) + assert all(abs(row["mean_q"] + 0.1) < 1e-12 for row in present) diff --git a/tests/test_lab043_calibration.py b/tests/test_lab043_calibration.py index 7d738f3..e360c08 100644 --- a/tests/test_lab043_calibration.py +++ b/tests/test_lab043_calibration.py @@ -764,6 +764,39 @@ def test_symbol_sample_phase_is_selected_separately(initial_sample_phase: int) - assert found["score"] > 0.99 +def test_spectral_tone_start_fallback_finds_narrow_tones_without_broadband_jump() -> None: + sample_rate = 240_000.0 + rng = np.random.default_rng(43) + samples = ( + rng.normal(0.0, 0.08, 240_000) + + 1j * rng.normal(0.0, 0.08, 240_000) + ) + tone_start = 96_000 + tone_count = 60_000 + indexes = np.arange(tone_count, dtype=np.float64) + samples[tone_start : tone_start + tone_count] += 0.025 * ( + np.exp(-1j * 2.0 * np.pi * 50_000.0 * indexes / sample_rate) + + np.exp(1j * 2.0 * np.pi * 50_000.0 * indexes / sample_rate) + ) + + with pytest.raises(RuntimeError, match="10"): + lab043.locate_calibration_tone_start( + samples, + sample_rate, + block_samples=2048, + hop_samples=1024, + ) + found = lab043.locate_calibration_tone_start_spectral( + samples, + sample_rate, + block_samples=8192, + hop_samples=2048, + required_consecutive_blocks=3, + ) + + assert tone_start - 8192 <= found <= tone_start + 2048 + + def test_one_control_packet_passes_crc() -> None: assert lab043.control_packet_crc_roundtrip(b"known payload") diff --git a/tests/test_lab043_hackrf_counter.py b/tests/test_lab043_hackrf_counter.py new file mode 100644 index 0000000..711caf4 --- /dev/null +++ b/tests/test_lab043_hackrf_counter.py @@ -0,0 +1,538 @@ +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from experiments import lab043_hackrf_counter as counter +from experiments import lab043_hackrf_hardware as hackrf +from experiments import lab043_pluto_to_rtlsdr as lab043 +from experiments import lab043_prbs11_hardware as pluto_hardware + + +class _FakeHackrfReceiver: + def __init__(self) -> None: + self.gain_calls: list[tuple[int, int]] = [] + + def configure_rx(self) -> dict: + return { + "set_center_frequency_hz": 435_000_000, + "set_sample_rate_hz": 2_400_000.0, + "rf_amp_enabled": False, + "lna_gain_db": 8, + "vga_gain_db": 8, + "antenna_bias_enabled": False, + } + + def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict: + self.gain_calls.append((lna_gain_db, vga_gain_db)) + return { + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "lna_gain_db": lna_gain_db, + "vga_gain_db": vga_gain_db, + "gain_readback_available": False, + "set_calls_succeeded": True, + } + + +class _FakeHackrfModule: + def __init__(self) -> None: + self.preflight_calls: list[tuple[int, int]] = [] + + def preflight_hackrf(self, lna_gain_db: int, vga_gain_db: int) -> dict: + self.preflight_calls.append((lna_gain_db, vga_gain_db)) + return _FakeHackrfReceiver().configure_rx() + + +class _FakePlutoModule: + def __init__(self) -> None: + self.preflight_uris: list[str] = [] + + @staticmethod + def discover_pluto_usb_uri() -> str: + return "usb:1.2.3" + + def preflight_pluto(self, uri: str) -> dict: + self.preflight_uris.append(uri) + return {"uri": uri, "context_opened": True} + + +def test_counter_uses_dedicated_output_directory() -> None: + assert counter.PROCESSED_DIRECTORY == Path( + "data/processed/lab043_hackrf_counter" + ) + + +@pytest.mark.parametrize( + "script_name", + ["lab043_hackrf_hardware.py", "lab043_hackrf_counter.py"], +) +def test_hackrf_scripts_support_direct_help_invocation(script_name: str) -> None: + project_root = Path(__file__).resolve().parents[1] + completed = subprocess.run( + [sys.executable, str(project_root / "experiments" / script_name), "--help"], + cwd=project_root, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + + +def test_counter_receiver_is_forced_to_working_point_8_8() -> None: + receiver = _FakeHackrfReceiver() + + parameters = counter.configure_counter_receiver(receiver) + + assert receiver.gain_calls == [(8, 8)] + assert parameters["lna_gain_db"] == 8 + assert parameters["vga_gain_db"] == 8 + assert parameters["rf_amp_enabled"] is False + assert parameters["antenna_bias_enabled"] is False + + +def test_counter_and_hackrf_module_share_working_point_8_8() -> None: + assert (counter.HACKRF_LNA_GAIN_DB, counter.HACKRF_VGA_GAIN_DB) == (8, 8) + assert (hackrf.HACKRF_LNA_GAIN_DB, hackrf.HACKRF_VGA_GAIN_DB) == (8, 8) + + +def test_counter_preflight_explicitly_requests_working_point_8_8() -> None: + module = _FakeHackrfModule() + + parameters = counter.preflight_counter_receiver(module) + + assert module.preflight_calls == [(8, 8)] + assert parameters["lna_gain_db"] == 8 + assert parameters["vga_gain_db"] == 8 + + +def test_counter_hardware_preflight_uses_discovered_usb_pluto() -> None: + hackrf_module = _FakeHackrfModule() + pluto_module = _FakePlutoModule() + + result = counter.preflight_counter_hardware(hackrf_module, pluto_module) + + assert pluto_module.preflight_uris == ["usb:1.2.3"] + assert result["pluto"]["uri"] == "usb:1.2.3" + assert hackrf_module.preflight_calls == [(8, 8)] + + +def test_pluto_working_point_is_checked_before_tx() -> None: + counter.validate_pluto_working_point( + { + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -30.0, + } + ) + counter.validate_pluto_working_point( + { + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_399_998, + "actual_gain_db": -30.0, + } + ) + + with pytest.raises(RuntimeError, match=r"Рабочая точка Pluto\+ не подтверждена"): + counter.validate_pluto_working_point( + { + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -20.0, + } + ) + + with pytest.raises(RuntimeError, match=r"Рабочая точка Pluto\+ не подтверждена"): + counter.validate_pluto_working_point( + { + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_399_000, + "actual_gain_db": -30.0, + } + ) + + +@pytest.mark.parametrize( + ("field", "bad_value"), + [ + ("set_center_frequency_hz", 434_999_999), + ("set_sample_rate_hz", 2_399_999.0), + ("lna_gain_db", 0), + ("vga_gain_db", 0), + ("rf_amp_enabled", True), + ("antenna_bias_enabled", True), + ], +) +def test_counter_rejects_any_other_hackrf_working_point( + field: str, + bad_value: object, +) -> None: + parameters = _FakeHackrfReceiver().configure_rx() + parameters[field] = bad_value + + with pytest.raises(RuntimeError, match="Рабочая точка HackRF не подтверждена"): + counter.validate_counter_working_point(parameters) + + +def test_event_rows_use_null_and_explicit_status_for_unknown_iq_count() -> None: + events = [ + { + "received_symbol_index": 0, + "type": "пропуск", + } + ] + boundaries = ( + SimpleNamespace(callback_index=1, accepted_end_sample=100), + ) + + rows, _ = counter._event_hardware_rows( + events, + np.asarray([100.0]), + boundaries, + np.zeros(200, dtype=np.complex64), + ) + + assert rows[0]["exact_lost_or_repeated_iq_samples"] is None + assert rows[0]["exact_iq_sample_count_status"] == "not_computed" + assert rows[0]["exact_iq_sample_count_reason"] + + +def test_hardware_orchestration_saves_raw_before_analysis_and_transmits_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + transmitted: list[np.ndarray] = [] + configured_gains: list[tuple[int, int]] = [] + + class FakeLibrary: + @staticmethod + def hackrf_is_streaming(_device: object) -> int: + return 1 + + class FakeDevice: + library = FakeLibrary() + device = object() + + def __enter__(self) -> "FakeDevice": + return self + + def __exit__(self, *_arguments: object) -> None: + return None + + def configure_rx(self) -> dict: + events.append("configure_hackrf") + return _FakeHackrfReceiver().configure_rx() + + def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict: + configured_gains.append((lna_gain_db, vga_gain_db)) + return { + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "lna_gain_db": lna_gain_db, + "vga_gain_db": vga_gain_db, + "gain_readback_available": False, + "set_calls_succeeded": True, + } + + def capture(self, target_sample_count: int) -> SimpleNamespace: + events.append("capture") + assert target_sample_count == 4 + return SimpleNamespace( + samples=np.zeros(target_sample_count, dtype=np.complex64), + boundaries=(), + diagnostics={"callback_count_total": 1}, + ) + + monkeypatch.setattr(counter, "RX_LEADING_SECONDS", 0.0) + monkeypatch.setattr(counter, "RX_TRAILING_SECONDS", 0.0) + monkeypatch.setattr(counter, "FIXED_TX_UPLOAD_MARGIN_SECONDS", 0.0) + monkeypatch.setattr(hackrf, "HACKRF_EXPECTED_CALLBACK_BYTES", 0) + monkeypatch.setattr(hackrf, "HackrfRxDevice", FakeDevice) + monkeypatch.setattr( + hackrf, + "preflight_hackrf", + lambda lna, vga: { + **_FakeHackrfReceiver().configure_rx(), + "lna_gain_db": lna, + "vga_gain_db": vga, + "serial_number": "test-serial", + "firmware_version": "test-firmware", + "libhackrf_version": "test-library", + }, + ) + monkeypatch.setattr( + pluto_hardware, + "discover_pluto_usb_uri", + lambda: "usb:1.2.3", + ) + monkeypatch.setattr( + pluto_hardware, + "preflight_pluto", + lambda uri: {"context_opened": True, "uri": uri}, + ) + monkeypatch.setattr( + pluto_hardware, + "configure_pluto", + lambda uri: ( + object(), + { + "uri": uri, + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -30.0, + }, + ), + ) + + def fake_transmit( + _device: object, + samples: np.ndarray, + _sample_rate_hz: float, + ) -> float: + events.append("transmit") + transmitted.append(np.asarray(samples).copy()) + return 0.0 + + monkeypatch.setattr( + pluto_hardware, + "transmit_noncyclic_buffer_once", + fake_transmit, + ) + monkeypatch.setattr( + counter, + "build_counter_waveform", + lambda _sample_rate_hz: ( + np.asarray([0.25 + 0.25j] * 4, dtype=np.complex64), + {"total_duration_seconds": 4 / counter.NOMINAL_SAMPLE_RATE}, + ), + ) + real_save_raw = lab043.save_raw_iq_before_processing + + def save_raw(*arguments: object, **keyword_arguments: object): + events.append("save_raw") + return real_save_raw(*arguments, **keyword_arguments) + + monkeypatch.setattr(lab043, "save_raw_iq_before_processing", save_raw) + + def fake_analyze(*_arguments: object) -> tuple[dict, dict]: + events.append("analyze") + return {"classification": "A"}, {} + + monkeypatch.setattr(counter, "analyze_hardware_counter", fake_analyze) + monkeypatch.setattr( + counter, + "save_counter_artifacts", + lambda *_arguments, **_keyword_arguments: events.append("save_processed"), + ) + + result = counter.run_one_hardware_counter( + raw_directory=tmp_path / "raw", + processed_directory=tmp_path / "processed", + ) + + assert result["processing_status"] == "success" + assert configured_gains == [(8, 8)] + assert len(transmitted) == 1 + assert events.count("transmit") == 1 + assert events.index("save_raw") < events.index("analyze") + assert result["waveform"]["purpose"] == ( + "цифровой счётчик времени, не измерение BER" + ) + + +def _reference(count: int = 20_000, start: int = 12_345) -> np.ndarray: + return counter.counter_symbol_ids(start, count) + + +def test_prbs20_period_and_unique_state_map() -> None: + bits = counter.prbs20_period_bits() + positions = counter.bit_state_to_position() + + assert len(bits) == (1 << 20) - 1 + assert positions[0] == -1 + assert np.count_nonzero(positions >= 0) == (1 << 20) - 1 + assert len(np.unique(positions[positions >= 0])) == (1 << 20) - 1 + + +def test_qpsk_alphabet_has_four_separated_unit_states() -> None: + states = counter.QPSK_STATES + + assert len(states) == 4 + assert np.allclose(np.abs(states), 1.0) + distances = np.abs(states[:, None] - states[None, :]) + assert np.min(distances[distances > 0]) == pytest.approx(np.sqrt(2.0)) + + +def test_hardware_waveform_parameters_are_fixed_before_tx() -> None: + waveform, parameters = counter.build_counter_waveform(2_399_998.0) + + assert counter.SYMBOL_RATE == 20_000.0 + assert parameters["samples_per_symbol"] == 120 + assert parameters["useful_symbol_count"] == 100_000 + assert parameters["transmitted_symbol_count"] == 110_000 + assert parameters["margin_symbol_count_each_side"] == 5_000 + assert len(waveform) == 13_200_000 + assert parameters["useful_duration_seconds"] == pytest.approx(5.00000416667) + + +def test_zero_cfo_exclusion_is_reproduced_and_diagnostic_override_recovers() -> None: + waveform, parameters = counter.build_counter_waveform(2_399_998.0) + + default = counter.recover_counter_from_iq(waveform, 2_399_998.0) + diagnostic = counter.recover_counter_from_iq( + waveform, + 2_399_998.0, + minimum_fourth_power_hz=0.0, + ) + + assert default["carrier"]["carrier_offset_hz"] == pytest.approx(44.99, abs=0.1) + assert ( + default["counter_analysis"]["recovered_symbol_count"] + < parameters["transmitted_symbol_count"] + ) + assert diagnostic["carrier"]["carrier_offset_hz"] == pytest.approx(0.0, abs=0.1) + assert ( + diagnostic["counter_analysis"]["recovered_symbol_count"] + == parameters["transmitted_symbol_count"] + ) + assert diagnostic["counter_analysis"]["stable_run_count"] == 1 + + +def test_ideal_counter_is_continuous() -> None: + result = counter.analyze_counter_symbols(_reference()) + + assert result["classification"] == "A" + assert result["events"] == [] + assert result["ordinary_symbol_error_count"] == 0 + + +@pytest.mark.parametrize("deleted", [1, 2, 10]) +def test_counter_detects_deleted_symbols(deleted: int) -> None: + reference = _reference() + received = np.delete(reference, np.s_[8_000 : 8_000 + deleted]) + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "B" + assert any( + event["type"] == "пропуск" + and event["minimum_confirmed_gap_symbols"] == deleted + for event in result["events"] + ) + + +@pytest.mark.parametrize("repeated", [1, 4]) +def test_counter_detects_repeated_symbols(repeated: int) -> None: + reference = _reference() + at = 8_000 + received = np.insert(reference, at, reference[at - repeated : at]) + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "B" + assert any( + event["type"] == "повтор" + and event["minimum_confirmed_gap_symbols"] == repeated + for event in result["events"] + ) + + +def test_counter_detects_foreign_insertion() -> None: + reference = _reference() + received = np.insert(reference, 8_000, np.asarray([0, 0, 0], dtype=np.uint8)) + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "B" + assert any(event["type"] == "вставка" for event in result["events"]) + + +def test_counter_detects_reordered_neighboring_blocks() -> None: + reference = _reference() + first = reference[7_000:7_200] + second = reference[7_200:7_450] + received = np.concatenate((reference[:7_000], second, first, reference[7_450:])) + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "B" + assert len(result["events"]) >= 2 + + +def test_partial_capture_from_middle_locates_position_without_false_jump() -> None: + received = counter.counter_symbol_ids(456_789, 12_000) + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "A" + assert result["runs"][0]["first_sequence_position"] == 456_789 + + +def test_capture_ending_early_is_not_a_discontinuity() -> None: + reference = _reference() + + result = counter.analyze_counter_symbols(reference[:9_321]) + + assert result["classification"] == "A" + assert result["events"] == [] + + +def test_single_symbol_error_is_not_a_large_position_jump() -> None: + received = _reference() + received[8_000] = (received[8_000] + 1) % 4 + + result = counter.analyze_counter_symbols(received) + + assert result["classification"] == "A" + assert result["events"] == [] + assert result["ordinary_symbol_error_count"] == 1 + + +@pytest.mark.parametrize( + ("carrier_offset_hz", "clock_error_ppm", "noise"), + [ + (1_000.0, 0.0, 0.0), + (1_000.0, 35.0, 0.0), + (1_000.0, 0.0, 0.12), + (-750.0, -30.0, 0.10), + ], +) +def test_iq_recovery_handles_frequency_clock_error_and_noise( + carrier_offset_hz: float, + clock_error_ppm: float, + noise: float, +) -> None: + sample_rate = 120_000.0 + samples_per_symbol = 12 + received = counter.synthesize_counter_iq( + start_position=345_678, + symbol_count=10_000, + samples_per_symbol=samples_per_symbol, + sample_rate_hz=sample_rate, + carrier_offset_hz=carrier_offset_hz, + sample_clock_error_ppm=clock_error_ppm, + noise_standard_deviation=noise, + ) + + recovered = counter.recover_counter_from_iq( + received, + sample_rate, + nominal_samples_per_symbol=samples_per_symbol, + fft_samples=65_536, + minimum_fourth_power_hz=200.0, + maximum_fourth_power_hz=10_000.0, + ) + + assert recovered["counter_analysis"]["classification"] == "A" + assert recovered["counter_analysis"]["anchor_count"] > 8_000 + assert recovered["counter_analysis"]["events"] == [] + assert recovered["carrier"]["carrier_offset_hz"] == pytest.approx( + carrier_offset_hz, abs=2.0 + ) diff --git a/tests/test_lab043_hackrf_gain_ladder.py b/tests/test_lab043_hackrf_gain_ladder.py new file mode 100644 index 0000000..c065355 --- /dev/null +++ b/tests/test_lab043_hackrf_gain_ladder.py @@ -0,0 +1,191 @@ +import numpy as np +import pytest + +from experiments import lab043_hackrf_gain_ladder as ladder + + +def _tone( + sample_rate: float, + seconds: float, + amplitude: float, + noise: float = 0.0, + second: float = 0.0, + third: float = 0.0, + frequency: float = 50_123.0, +) -> np.ndarray: + rng = np.random.default_rng(43043) + count = round(sample_rate * seconds) + indexes = np.arange(count, dtype=np.float64) + result = amplitude * np.exp(1j * 2.0 * np.pi * frequency * indexes / sample_rate) + result += second * np.exp(1j * 2.0 * np.pi * 2.0 * frequency * indexes / sample_rate) + result += third * np.exp(1j * 2.0 * np.pi * 3.0 * frequency * indexes / sample_rate) + if noise: + result += noise * (rng.normal(size=count) + 1j * rng.normal(size=count)) + return result.astype(np.complex64) + + +def test_fixed_gain_ladder_is_valid_and_monotonic() -> None: + assert ladder.GAIN_LADDER == ( + (0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24) + ) + assert [sum(point) for point in ladder.GAIN_LADDER] == sorted( + sum(point) for point in ladder.GAIN_LADDER + ) + + +def test_measure_tone_finds_frequency_margin_and_no_overload() -> None: + sample_rate = 240_000.0 + samples = _tone(sample_rate, 3.0, amplitude=0.20, noise=0.002) + + metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536) + + assert metrics["tone_frequency_hz"] == pytest.approx(50_123.0, abs=2.0) + assert metrics["tone_over_noise_db"] > 35.0 + assert metrics["rms_amplitude"] == pytest.approx(0.20, abs=0.01) + assert metrics["clipped_component_fraction"] == 0.0 + assert not metrics["second_harmonic"]["credible"] + assert not metrics["third_harmonic"]["credible"] + assert not metrics["stop_gain_escalation"] + + +def test_strong_tone_without_harmonics_is_not_nonlinear() -> None: + sample_rate = 240_000.0 + samples = _tone(sample_rate, 3.0, amplitude=0.55, noise=0.002) + + metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536) + + assert not metrics["second_harmonic"]["credible"] + assert not metrics["third_harmonic"]["credible"] + assert not metrics["harmonic_nonlinearity_detected"] + + +def test_credible_second_harmonic_is_detected() -> None: + sample_rate = 240_000.0 + samples = _tone(sample_rate, 3.0, amplitude=0.30, noise=0.001, second=0.03) + + metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536) + + assert metrics["second_harmonic"]["credible"] + assert metrics["second_harmonic"]["nonlinear"] + assert metrics["second_harmonic"]["relative_to_fundamental_db"] == pytest.approx(-20.0, abs=1.0) + assert metrics["stop_gain_escalation"] + + +def test_credible_third_harmonic_is_detected() -> None: + sample_rate = 240_000.0 + samples = _tone(sample_rate, 3.0, amplitude=0.30, noise=0.001, third=0.015) + + metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536) + + assert metrics["third_harmonic"]["credible"] + assert metrics["third_harmonic"]["nonlinear"] + assert metrics["third_harmonic"]["relative_to_fundamental_db"] == pytest.approx(-26.0, abs=1.0) + assert metrics["stop_gain_escalation"] + + +def test_peak_high_relative_to_weak_tone_but_below_local_margin_is_not_credible() -> None: + sample_rate = 1_000_000.0 + fft_samples = 10_000 + frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate) + powers = np.full(fft_samples, 1e-5, dtype=np.float64) + expected = 100_000.0 + peak_index = int(np.argmin(np.abs(frequencies - expected))) + powers[peak_index] = 3e-5 + + result = ladder.analyze_harmonic( + powers, + frequencies, + fundamental_frequency_hz=50_000.0, + fundamental_power=1e-6, + order=2, + sample_rate_hz=sample_rate, + ) + + assert result["relative_to_fundamental_db"] > 10.0 + assert result["above_local_noise_db"] < ladder.HARMONIC_DETECTION_MARGIN_DB + assert not result["credible"] + assert not result["nonlinear"] + + +def test_measure_tone_stops_on_near_limit_samples() -> None: + sample_rate = 240_000.0 + samples = _tone(sample_rate, 2.0, amplitude=0.95) + + metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536) + + assert metrics["near_limit_component_fraction"] > 0.0 + assert metrics["stop_gain_escalation"] + + +def test_harmonic_frequency_aliases_into_nyquist_band() -> None: + assert ladder.alias_frequency_to_nyquist(900_000.0, 1_000_000.0) == pytest.approx(-100_000.0) + assert ladder.alias_frequency_to_nyquist(1_350_000.0, 1_000_000.0) == pytest.approx(350_000.0) + + sample_rate = 1_000_000.0 + fft_samples = 100_000 + frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate) + powers = np.full(fft_samples, 1e-10, dtype=np.float64) + powers[int(np.argmin(np.abs(frequencies + 100_000.0)))] = 1e-3 + powers[int(np.argmin(np.abs(frequencies - 350_000.0)))] = 2.5e-4 + second = ladder.analyze_harmonic( + powers, frequencies, 450_000.0, 0.1, 2, sample_rate + ) + third = ladder.analyze_harmonic( + powers, frequencies, 450_000.0, 0.1, 3, sample_rate + ) + assert second["expected_frequency_hz"] == pytest.approx(-100_000.0) + assert third["expected_frequency_hz"] == pytest.approx(350_000.0) + assert second["credible"] + assert third["credible"] + + +def test_choose_preferred_gain_uses_lowest_gain_reaching_desirable_margin() -> None: + def row(index: int, lna: int, vga: int, margin: float) -> dict: + return { + "step_index": index, + "lna_gain_db": lna, + "vga_gain_db": vga, + "metrics": { + "tone_over_noise_db": margin, + "maximum_component": 0.4, + "stop_gain_escalation": False, + }, + } + + rows = [row(0, 0, 0, 20.0), row(1, 8, 8, 38.5), row(2, 16, 8, 40.0)] + + selected = ladder.choose_preferred_gain(rows) + + assert selected is rows[1] + + +def test_choose_preferred_gain_rejects_unsafe_rows() -> None: + row = { + "step_index": 0, + "lna_gain_db": 0, + "vga_gain_db": 0, + "metrics": { + "tone_over_noise_db": 60.0, + "maximum_component": 0.95, + "stop_gain_escalation": True, + }, + } + + assert ladder.choose_preferred_gain([row]) is None + + +def test_choose_preferred_gain_uses_near_best_when_target_not_reached() -> None: + def row(index: int, lna: int, vga: int, margin: float) -> dict: + return { + "step_index": index, + "lna_gain_db": lna, + "vga_gain_db": vga, + "metrics": { + "tone_over_noise_db": margin, + "maximum_component": 0.4, + "stop_gain_escalation": False, + }, + } + + rows = [row(0, 0, 0, 20.0), row(1, 8, 8, 31.5), row(2, 16, 8, 33.0)] + assert ladder.choose_preferred_gain(rows) is rows[1] diff --git a/tests/test_lab043_hackrf_hardware.py b/tests/test_lab043_hackrf_hardware.py new file mode 100644 index 0000000..abed944 --- /dev/null +++ b/tests/test_lab043_hackrf_hardware.py @@ -0,0 +1,203 @@ +import ctypes + +import numpy as np +import pytest + +from experiments import lab043_hackrf_hardware as hackrf +from experiments import lab043_hackrf_continuity as continuity + + +def _block(first: int, complex_samples: int) -> bytes: + values = (np.arange(2 * complex_samples, dtype=np.int16) + first) % 255 - 127 + return values.astype(np.int8).tobytes() + + +def test_signed_iq_bytes_to_complex64_preserves_iq_order() -> None: + raw = np.asarray([-128, 127, -64, 32], dtype=np.int8).tobytes() + + result = hackrf.signed_iq_bytes_to_complex64(raw) + + assert result.dtype == np.complex64 + assert np.allclose(result, [-1.0 + 127j / 128.0, -0.5 + 0.25j]) + + +def test_signed_iq_bytes_rejects_incomplete_complex_sample() -> None: + with pytest.raises(ValueError, match="чётное"): + hackrf.signed_iq_bytes_to_complex64(b"\x01") + + +def test_callback_copy_uses_only_valid_length_and_survives_source_reuse() -> None: + source = (ctypes.c_uint8 * 8)(1, 2, 3, 4, 90, 91, 92, 93) + + copied = hackrf.copy_valid_callback_bytes(source, buffer_length=8, valid_length=4) + for index in range(8): + source[index] = 0 + + assert copied == b"\x01\x02\x03\x04" + + +def test_callback_copy_rejects_odd_valid_length() -> None: + source = (ctypes.c_uint8 * 4)(1, 2, 3, 4) + + with pytest.raises(ValueError, match="чётным"): + hackrf.copy_valid_callback_bytes(source, buffer_length=4, valid_length=3) + + +def test_callback_assembler_discards_fixed_warmup_and_keeps_order() -> None: + blocks = [_block(0, 4), _block(20, 4), _block(40, 4), _block(60, 4)] + + samples, boundaries, diagnostics = hackrf.assemble_callback_blocks( + blocks, target_sample_count=9, warmup_callback_count=1 + ) + + expected = hackrf.signed_iq_bytes_to_complex64( + blocks[1] + blocks[2] + blocks[3][:2] + ) + assert np.array_equal(samples, expected) + assert [row.callback_index for row in boundaries] == [1, 2, 3] + assert [row.accepted_end_sample for row in boundaries] == [4, 8, 9] + assert [row.accepted_bytes for row in boundaries] == [8, 8, 2] + assert [row.buffer_length for row in boundaries] == [8, 8, 8] + assert diagnostics["callback_indexes_sequential"] + assert diagnostics["program_queue_missing_callback_count"] == 0 + assert diagnostics["accepted_sample_count"] == 9 + + +def test_callback_assembler_keeps_distinct_blocks_without_duplication() -> None: + blocks = [b"\x01\x02\x03\x04", b"\x05\x06\x07\x08"] + + samples, boundaries, diagnostics = hackrf.assemble_callback_blocks( + blocks, + target_sample_count=4, + warmup_callback_count=0, + buffer_lengths=[16, 16], + ) + + expected = hackrf.signed_iq_bytes_to_complex64(blocks[0] + blocks[1]) + assert np.array_equal(samples, expected) + assert [row.callback_index for row in boundaries] == [0, 1] + assert [row.valid_bytes for row in boundaries] == [4, 4] + assert [row.buffer_length for row in boundaries] == [16, 16] + assert diagnostics["accepted_bytes"] == sum(map(len, blocks)) + + +def test_capture_contract_keeps_original_int8_bytes_separate_from_complex64() -> None: + raw = np.asarray([-128, 127, -64, 32], dtype=np.int8).tobytes() + capture = hackrf.HackrfCapture( + samples=hackrf.signed_iq_bytes_to_complex64(raw), + boundaries=(), + diagnostics={}, + raw_bytes=raw, + ) + + assert capture.raw_bytes == raw + assert capture.samples.dtype == np.complex64 + assert np.array_equal( + capture.samples, + hackrf.signed_iq_bytes_to_complex64(capture.raw_bytes), + ) + + +@pytest.mark.parametrize("timeout_seconds", [0.0, -1.0, float("nan")]) +def test_capture_rejects_invalid_timeout_before_hardware_access( + timeout_seconds: float, +) -> None: + receiver = hackrf.HackrfRxDevice() + + with pytest.raises(ValueError, match="Тайм-аут"): + receiver.capture(1, timeout_seconds=timeout_seconds) + + +def test_callback_assembler_rejects_short_stream() -> None: + with pytest.raises(RuntimeError, match="короче"): + hackrf.assemble_callback_blocks( + [_block(0, 4), _block(20, 4)], + target_sample_count=9, + warmup_callback_count=1, + ) + + +def test_callback_assembler_rejects_odd_callback() -> None: + with pytest.raises(ValueError, match="нечётное"): + hackrf.assemble_callback_blocks( + [b"\x00\x01", b"\x02"], + target_sample_count=1, + warmup_callback_count=1, + ) + + +def test_hackrf_configuration_is_fixed_and_rx_only() -> None: + assert hackrf.HACKRF_CENTER_HZ == 435_000_000 + assert hackrf.HACKRF_SAMPLE_RATE_HZ == 2_400_000.0 + assert hackrf.HACKRF_RF_AMP == 0 + assert hackrf.HACKRF_ANTENNA_BIAS == 0 + assert hackrf.HACKRF_LNA_GAIN_DB == 8 + assert hackrf.HACKRF_VGA_GAIN_DB == 8 + assert not hasattr(hackrf.HackrfRxDevice, "transmit") + + +@pytest.mark.parametrize( + ("lna", "vga"), + [(0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24), (40, 62)], +) +def test_hackrf_rx_gain_steps_accept_official_values(lna: int, vga: int) -> None: + assert hackrf.validate_hackrf_rx_gains(lna, vga) == (lna, vga) + + +@pytest.mark.parametrize("lna", [-8, 4, 48]) +def test_hackrf_rx_gain_steps_reject_invalid_lna(lna: int) -> None: + with pytest.raises(ValueError, match="LNA"): + hackrf.validate_hackrf_rx_gains(lna, 0) + + +@pytest.mark.parametrize("vga", [-2, 1, 64]) +def test_hackrf_rx_gain_steps_reject_invalid_vga(vga: int) -> None: + with pytest.raises(ValueError, match="VGA"): + hackrf.validate_hackrf_rx_gains(0, vga) + + +def test_periodic_continuity_tone_has_identical_wrap_phase() -> None: + tone, frequency = continuity.build_periodic_tone(2_400_000.0) + + phase_step = np.exp(1j * 2.0 * np.pi * frequency / 2_400_000.0) + assert len(tone) == 48 * continuity.CYCLIC_PERIOD_COUNT + assert np.allclose(tone[0], tone[-1] * phase_step, atol=2e-6) + + +def test_boundary_analysis_finds_known_phase_step_at_callback_boundary() -> None: + sample_rate = 240_000.0 + frequency = 5_000.0 + sample_count = 240_000 + boundary_sample = sample_count // 2 + indexes = np.arange(sample_count, dtype=np.float64) + samples = np.exp(1j * 2.0 * np.pi * frequency * indexes / sample_rate) + samples[boundary_sample:] *= np.exp(0.8j) + boundary = hackrf.HackrfBoundary( + callback_index=1, + valid_bytes=2 * boundary_sample, + accepted_bytes=2 * boundary_sample, + accepted_start_sample=0, + accepted_end_sample=boundary_sample, + monotonic_seconds=0.5, + block_sha256="synthetic", + buffer_length=2 * boundary_sample, + ) + + rows, summary = continuity.analyze_callback_boundaries( + samples.astype(np.complex64), + (boundary,), + sample_rate, + frequency, + stable_start_sample=0, + stable_end_sample=sample_count, + side_samples=32_768, + block_samples=8_192, + hop_samples=2_048, + ) + + assert summary["confirmed_jump_count"] == 1 + assert rows[0]["additional_phase_jump_rad"] == pytest.approx(0.8, abs=0.01) + assert rows[0]["equivalent_sample_shift_principal"] == pytest.approx( + 0.8 / (2.0 * np.pi * frequency / sample_rate), abs=0.05 + ) + assert rows[0]["relation_to_callback_boundary"] == "consistent_with_exact_boundary" diff --git a/tests/test_lab043_hackrf_zero_if_compare.py b/tests/test_lab043_hackrf_zero_if_compare.py new file mode 100644 index 0000000..8659bbb --- /dev/null +++ b/tests/test_lab043_hackrf_zero_if_compare.py @@ -0,0 +1,428 @@ +import ast +import inspect +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from experiments import lab043_hackrf_counter as counter +from experiments import lab043_hackrf_hardware as hackrf +from experiments import lab043_hackrf_zero_if_compare as comparison +from experiments import lab043_prbs11_hardware as pluto_hardware + + +def test_comparison_uses_dedicated_output_directory() -> None: + assert comparison.PROCESSED_DIRECTORY == Path( + "data/processed/lab043_hackrf_zero_if_compare" + ) + + +def test_composite_waveform_has_seven_fixed_sections_and_identical_counters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sample_rate = 240_000.0 + base_counter = np.asarray( + [0.35 + 0.0j, 0.0 + 0.35j, -0.35 + 0.0j, 0.0 - 0.35j], + dtype=np.complex64, + ) + monkeypatch.setattr( + counter, + "build_counter_waveform", + lambda _sample_rate: (base_counter.copy(), {"samples_per_symbol": 120}), + ) + + waveform, metadata = comparison.build_composite_waveform(sample_rate) + + names = [row["name"] for row in metadata["sections"]] + assert names == [ + "leading_zero", + "tone_plus_50khz", + "guard_before_shifted_counter", + "counter_plus_50khz", + "guard_after_shifted_counter", + "counter_zero_if", + "trailing_zero", + ] + sections = {row["name"]: row for row in metadata["sections"]} + shifted_row = sections["counter_plus_50khz"] + centered_row = sections["counter_zero_if"] + shifted = waveform[ + shifted_row["start_sample"] : shifted_row["end_sample_exclusive"] + ] + centered = waveform[ + centered_row["start_sample"] : centered_row["end_sample_exclusive"] + ] + indexes = np.arange(len(shifted), dtype=np.float64) + restored = shifted * np.exp( + -2j * np.pi * comparison.TONE_OFFSET_HZ * indexes / sample_rate + ) + assert np.allclose(restored, centered, atol=2e-7) + assert metadata["digital_sequences_bit_identical"] is True + assert ( + metadata["counter_symbol_ids_sha256_shifted"] + == metadata["counter_symbol_ids_sha256_centered"] + ) + assert metadata["counter_amplitude"] == metadata["tone_amplitude"] == 0.35 + for name in ( + "leading_zero", + "guard_before_shifted_counter", + "guard_after_shifted_counter", + "trailing_zero", + ): + row = sections[name] + assert np.count_nonzero(waveform[row["start_sample"] : row["end_sample_exclusive"]]) == 0 + + +def test_pluto_scaling_is_in_place_and_does_not_clip() -> None: + waveform = np.asarray([0.35 + 0.0j, 0.0 - 0.35j], dtype=np.complex64) + identity = id(waveform) + + result = comparison.scale_pluto_samples_in_place(waveform) + + assert id(result) == identity + assert result.dtype == np.complex64 + assert np.max(np.abs(result)) == pytest.approx(0.35 * 2**14, rel=1e-6) + + +def test_pluto_validation_includes_rf_bandwidth() -> None: + parameters = { + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -30.0, + "actual_rf_bandwidth_hz": 200_000, + } + comparison.validate_pluto_working_point(parameters) + + parameters["actual_rf_bandwidth_hz"] = 100_000 + with pytest.raises(RuntimeError, match="RF bandwidth"): + comparison.validate_pluto_working_point(parameters) + + +def test_dual_raw_is_hashed_reread_and_exactly_converted(tmp_path: Path) -> None: + raw = np.asarray([-128, 127, -64, 32, 0, -1], dtype=np.int8).tobytes() + samples = hackrf.signed_iq_bytes_to_complex64(raw) + + int8_path, iq_path, metadata_path, document = comparison.save_dual_raw_before_processing( + tmp_path / "capture", + raw, + samples, + {"capture_status": "captured", "processing_status": "pending"}, + ) + + assert int8_path.read_bytes() == raw + assert np.array_equal(np.load(iq_path, allow_pickle=False), samples) + assert document["raw_verified"] is True + assert document["int8_to_complex64"]["verified"] is True + assert document["int8_to_complex64"]["byte_order"] == "interleaved I then Q" + assert document["int8_sha256"] == comparison._hash_file(int8_path) + assert document["iq_sha256"] == comparison._hash_file(iq_path) + assert metadata_path.exists() + + +def test_background_metrics_keep_dc_and_signed_code_distributions() -> None: + codes = np.asarray([[-2, 4], [-2, 4], [-1, 3], [-2, 4]], dtype=np.int8) + samples = hackrf.signed_iq_bytes_to_complex64(codes.tobytes()) + + metrics = comparison.background_metrics(samples, codes, 2_400_000.0) + + assert metrics["i_code_distribution"] == {"-2": 3, "-1": 1} + assert metrics["q_code_distribution"] == {"3": 1, "4": 3} + assert metrics["mean_i"] == pytest.approx(-1.75 / 128.0) + assert metrics["mean_q"] == pytest.approx(3.75 / 128.0) + assert metrics["dc_correction_applied"] is False + + +def test_known_counter_analysis_recovers_shifted_synthetic_without_dc_correction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sample_rate = 240_000.0 + symbol_count = 1_000 + samples_per_symbol = 120 + carrier_hz = 50_200.0 + expected_start = 80_037 + monkeypatch.setattr(counter, "TRANSMITTED_SYMBOL_COUNT", symbol_count) + monkeypatch.setattr(comparison, "MATCH_REFERENCE_START_SYMBOL", 100) + monkeypatch.setattr(comparison, "MATCH_REFERENCE_SYMBOL_COUNT", 300) + symbols = counter.counter_qpsk_symbols(0, symbol_count) + base = np.repeat(symbols, samples_per_symbol) + indexes = np.arange(len(base), dtype=np.float64) + signal = (0.02 * base * np.exp(2j * np.pi * carrier_hz * indexes / sample_rate)).astype( + np.complex64 + ) + raw = np.full(320_000, 0.01 + 0.02j, dtype=np.complex64) + raw[expected_start : expected_start + len(signal)] += signal + + result = comparison.analyze_counter_section( + raw, + expected_start + 40, + carrier_hz, + sample_rate, + background_power=0.01**2 + 0.02**2, + ) + + assert result["start_sample"] == expected_start + assert result["measured_carrier_hz"] == pytest.approx(carrier_hz, abs=0.1) + assert result["symbol_error_rate"] == 0.0 + assert result["dc_correction_applied"] is False + assert result["new_filter_applied"] is False + assert result["equalizer_applied"] is False + + +def test_hardware_runner_contains_one_tx_call_and_no_forbidden_experiment_calls() -> None: + tree = ast.parse(inspect.getsource(comparison.run_one_zero_if_comparison)) + calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)] + attributes = [node.func.attr for node in calls if isinstance(node.func, ast.Attribute)] + + assert attributes.count("transmit_noncyclic_buffer_once") == 1 + forbidden = { + "run_one_hackrf_s", + "run_one_hardware_counter", + "run_one_hardware_prbs11", + "run_packet", + "run_jpeg", + } + assert forbidden.isdisjoint(attributes) + + +def test_hardware_orchestration_transmits_once_and_saves_both_raw_before_analysis( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + transmitted: list[np.ndarray] = [] + + class FakeLibrary: + @staticmethod + def hackrf_is_streaming(_device: object) -> int: + return 1 + + class FakeReceiver: + library = FakeLibrary() + device = object() + + def __enter__(self): + return self + + def __exit__(self, *_arguments: object) -> None: + return None + + def configure_rx(self) -> dict: + return { + "set_center_frequency_hz": 435_000_000, + "set_sample_rate_hz": 2_400_000.0, + "rf_amp_enabled": False, + "lna_gain_db": 8, + "vga_gain_db": 8, + "antenna_bias_enabled": False, + } + + def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict: + assert (lna_gain_db, vga_gain_db) == (8, 8) + return { + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "lna_gain_db": lna_gain_db, + "vga_gain_db": vga_gain_db, + } + + def capture(self, target_sample_count: int, timeout_seconds: float): + events.append("capture") + assert timeout_seconds == comparison.RX_TIMEOUT_SECONDS + raw = bytes(2 * target_sample_count) + return SimpleNamespace( + samples=hackrf.signed_iq_bytes_to_complex64(raw), + raw_bytes=raw, + boundaries=(), + diagnostics={ + "callback_count_total": 1, + "stream_started_monotonic": 0.0, + }, + ) + + monkeypatch.setattr(comparison, "RX_LEADING_SECONDS", 0.0) + monkeypatch.setattr(comparison, "RX_TRAILING_SECONDS", 0.0) + monkeypatch.setattr(comparison, "TX_UPLOAD_MARGIN_SECONDS", 0.0) + monkeypatch.setattr(hackrf, "HACKRF_EXPECTED_CALLBACK_BYTES", 0) + monkeypatch.setattr(hackrf, "HackrfRxDevice", FakeReceiver) + monkeypatch.setattr( + hackrf, + "preflight_hackrf", + lambda lna, vga: { + "set_center_frequency_hz": 435_000_000, + "set_sample_rate_hz": 2_400_000.0, + "rf_amp_enabled": False, + "lna_gain_db": lna, + "vga_gain_db": vga, + "antenna_bias_enabled": False, + "serial_number": "serial", + "firmware_version": "firmware", + "libhackrf_version": "library", + }, + ) + monkeypatch.setattr(pluto_hardware, "discover_pluto_usb_uri", lambda: "usb:1.2.3") + monkeypatch.setattr( + pluto_hardware, + "preflight_pluto", + lambda uri: {"uri": uri, "context_opened": True}, + ) + monkeypatch.setattr( + pluto_hardware, + "configure_pluto", + lambda uri: ( + object(), + { + "uri": uri, + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -30.0, + "actual_rf_bandwidth_hz": 200_000, + }, + ), + ) + monkeypatch.setattr( + comparison, + "build_composite_waveform", + lambda _sample_rate: ( + np.asarray([0.1 + 0.1j] * 4, dtype=np.complex64), + {"total_duration_seconds": 4 / 2_400_000.0, "sections": [], "total_sample_count": 4}, + ), + ) + + def fake_transmit(_device: object, samples: np.ndarray, _sample_rate: float) -> float: + events.append("transmit") + transmitted.append(np.asarray(samples).copy()) + return 0.0 + + monkeypatch.setattr(pluto_hardware, "transmit_noncyclic_buffer_once", fake_transmit) + real_save = comparison.save_dual_raw_before_processing + + def save_raw(*arguments: object, **keyword_arguments: object): + events.append("save_raw") + return real_save(*arguments, **keyword_arguments) + + monkeypatch.setattr(comparison, "save_dual_raw_before_processing", save_raw) + + def analyze(*_arguments: object) -> dict: + events.append("analyze") + return { + "counter_plus_50khz": {}, + "counter_zero_if": {}, + } + + monkeypatch.setattr(comparison, "analyze_saved_capture", analyze) + monkeypatch.setattr(comparison, "save_processed_artifacts", lambda *_arguments: ()) + + result = comparison.run_one_zero_if_comparison(tmp_path, tmp_path / "processed") + + assert result["processing_status"] == "success" + assert events.count("transmit") == 1 + assert len(transmitted) == 1 + assert events.index("save_raw") < events.index("analyze") + assert Path(result["int8_path"]).suffix == ".int8" + assert Path(result["iq_path"]).suffix == ".npy" + + +def test_rx_failure_before_stream_confirmation_prevents_tx( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + transmit_calls = 0 + + class FailedLibrary: + @staticmethod + def hackrf_is_streaming(_device: object) -> int: + return 0 + + class FailedReceiver: + library = FailedLibrary() + device = object() + + def __enter__(self): + return self + + def __exit__(self, *_arguments: object) -> None: + return None + + def configure_rx(self) -> dict: + return { + "set_center_frequency_hz": 435_000_000, + "set_sample_rate_hz": 2_400_000.0, + "rf_amp_enabled": False, + "lna_gain_db": 8, + "vga_gain_db": 8, + "antenna_bias_enabled": False, + } + + def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict: + return { + "rf_amp_enabled": False, + "antenna_bias_enabled": False, + "lna_gain_db": lna_gain_db, + "vga_gain_db": vga_gain_db, + } + + @staticmethod + def capture(_target_sample_count: int, timeout_seconds: float): + raise RuntimeError(f"synthetic RX failure before streaming ({timeout_seconds})") + + monkeypatch.setattr(hackrf, "HackrfRxDevice", FailedReceiver) + monkeypatch.setattr( + hackrf, + "preflight_hackrf", + lambda lna, vga: { + "set_center_frequency_hz": 435_000_000, + "set_sample_rate_hz": 2_400_000.0, + "rf_amp_enabled": False, + "lna_gain_db": lna, + "vga_gain_db": vga, + "antenna_bias_enabled": False, + "serial_number": "serial", + "firmware_version": "firmware", + "libhackrf_version": "library", + }, + ) + monkeypatch.setattr(pluto_hardware, "discover_pluto_usb_uri", lambda: "usb:1.2.3") + monkeypatch.setattr( + pluto_hardware, + "preflight_pluto", + lambda uri: {"uri": uri, "context_opened": True}, + ) + monkeypatch.setattr( + pluto_hardware, + "configure_pluto", + lambda uri: ( + object(), + { + "uri": uri, + "actual_center_frequency_hz": 435_000_000, + "actual_sample_rate_hz": 2_400_000, + "actual_gain_db": -30.0, + "actual_rf_bandwidth_hz": 200_000, + }, + ), + ) + monkeypatch.setattr( + comparison, + "build_composite_waveform", + lambda _sample_rate: ( + np.asarray([0.1 + 0.1j], dtype=np.complex64), + {"total_duration_seconds": 1 / 2_400_000.0}, + ), + ) + + def forbidden_transmit(*_arguments: object) -> float: + nonlocal transmit_calls + transmit_calls += 1 + return 0.0 + + monkeypatch.setattr( + pluto_hardware, + "transmit_noncyclic_buffer_once", + forbidden_transmit, + ) + + with pytest.raises(RuntimeError, match="до TX; Pluto TX не включён"): + comparison.run_one_zero_if_comparison(tmp_path, tmp_path / "processed") + + assert transmit_calls == 0