diff --git a/data/processed/video005/benchmark.json b/data/processed/video005/benchmark.json new file mode 100644 index 0000000..4e7acdf --- /dev/null +++ b/data/processed/video005/benchmark.json @@ -0,0 +1,1136 @@ +{ + "status": "PARTIAL", + "seed": 20260917, + "rf_tx": 0, + "sdr_hardware": "NOT ACCESSED", + "radio": { + "packets": 32, + "iq_samples": 3886112, + "iq_duration_s": 0.485764, + "signal_sha256": "d34c4fbba04708180f60ed759cef8bfa9f67827a57ea5ff201d94701bf32afd2", + "jit_first_run_s": 1.2032661999110132, + "jit_first_outcome": { + "crc_valid_packets": 32, + "exact_packets": 32, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "0": "5311026a6748649bfa20079af68bdb9ec9df2b2f9c376ef7ca879a2a6dfe6f45", + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12", + "2": "fa629eacc85a00b4389cd0d1f78f67c9ecab759cd5ff5b6250234b080610a55f", + "3": "e4323493982a55413f2bc44470daccda128f85d640795470493ad921e9495a96", + "4": "0a39a0f221a12aa8a408edad1256b5d49f179d4e6df1c513db5006f4123725cd", + "5": "1982f5dd5cecb78dce88d4d4c108e7fdc2c769926f5f7efa8fc42ae1232135ea", + "6": "29f1d6c8a17e8de23979600ced9a4a3a1982645c8421fe9029aa0e505dbc1910", + "7": "583b4edcbe415655a77ca8888dcc45d28834ecd18628e53933e7ab1a74211396", + "8": "7080ab484f0f050548e736eda57a0e3a93941e1d3b816c071448ee70fa8a5ea4", + "9": "11bac43be1d14f232b3ac554cf1d7933e2ef5afa091c79f0ddd6ded434340a0d", + "10": "553cab880d7b7ffccc19cf18b034ab9ce52b6589fbed501bf56a9741e874da8e", + "11": "26b2bf0c3b022108466940d6343ac46dd8149a499106f33dae35cabb6b4dd82a", + "12": "1f7ae3b9ba16e252167ba5a2bbbaeef24511ff7859080650f25b5a4c2be16b39", + "13": "2151eb463c52d02e0afcf14ac70fa34b3084d6ab4441962e0174c9c8368cc44b", + "14": "c6d55fc813f06d2360fcd2ffc34d36b068f3f050d5e2a7922b27a537483c36ac", + "15": "37d6e5c2742eeb8c487180af468857aba3afbdba16b953a00c49a0c40e2da604", + "16": "9af497552263a76787021d52e236d187559bbd05390a97f210bfd38d2b411ec5", + "17": "55637fd7ad6972bb456ea79ed65303467abfb25227a88c317c307b0ef34c16eb", + "18": "5789defb44c76625c7d983fe8ddd7f9285109a4f9d975459dab74dde360e7310", + "19": "3d84fbc6b21c970ed9cdb263874e4ea7efb2bf460a9ce3a6d7308a0da427adb8", + "20": "7852b587fa1294d76f37f1dde0a7fcf9f7f97c2e97c9dfe63209456b064cf3c7", + "21": "b1a6aae79e91cb981f5b5538cc432249139ca83f73f329779d353764af7ce04b", + "22": "c0c9d9d70987146311e00a64d2557299417a1ea93f02f56975e475884058cf31", + "23": "8c0b1ea7bd886cbcdfcb2cbbcb28f711ff20ce1daa1f9195c6c4990a8f227d70", + "24": "c626576ce733b83035ebdd6487087aac272862c2f5a7fe5c876d86ffa21e8997", + "25": "6351fd9ba69e0cb84443c1f80d326322cd4dc1d1cca567bde42ad1cff9a0f692", + "26": "695fe05691c137a14a1ca8af1bb54f7f6e2e82d2483015dcaef4ecd2fc3a613b", + "27": "d1aa07b9a5e52aedd3091a4068ceda7b294791bb1061b3662ddd732831b97416", + "28": "02fc6e9ff163fae5c23e54cfc32c67d46db2ccb3697b14ad722beb9acee8532f", + "29": "3e2a1a89d5e330e1a8b3376e92f90358cd3190669ebd37a5237f93f011e3cdeb", + "30": "db0cd0a6a95bc97a9459b745a23602cfc1c2c92c0bd42872e611d5b44e1b0179", + "31": "9d4a70c525a367ac9b490650e50d12bbd05892de318ffab0ed72390a5e3bee19" + } + }, + "warm_times_s": [ + 0.28091650013811886, + 0.2992595999967307, + 0.2681279999669641 + ], + "warm_median_s": 0.28091650013811886, + "realtime_factor": 0.578298309751482, + "realtime_speed": 1.7292113484297407, + "strict_outcome": { + "crc_valid_packets": 32, + "exact_packets": 32, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "0": "5311026a6748649bfa20079af68bdb9ec9df2b2f9c376ef7ca879a2a6dfe6f45", + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12", + "2": "fa629eacc85a00b4389cd0d1f78f67c9ecab759cd5ff5b6250234b080610a55f", + "3": "e4323493982a55413f2bc44470daccda128f85d640795470493ad921e9495a96", + "4": "0a39a0f221a12aa8a408edad1256b5d49f179d4e6df1c513db5006f4123725cd", + "5": "1982f5dd5cecb78dce88d4d4c108e7fdc2c769926f5f7efa8fc42ae1232135ea", + "6": "29f1d6c8a17e8de23979600ced9a4a3a1982645c8421fe9029aa0e505dbc1910", + "7": "583b4edcbe415655a77ca8888dcc45d28834ecd18628e53933e7ab1a74211396", + "8": "7080ab484f0f050548e736eda57a0e3a93941e1d3b816c071448ee70fa8a5ea4", + "9": "11bac43be1d14f232b3ac554cf1d7933e2ef5afa091c79f0ddd6ded434340a0d", + "10": "553cab880d7b7ffccc19cf18b034ab9ce52b6589fbed501bf56a9741e874da8e", + "11": "26b2bf0c3b022108466940d6343ac46dd8149a499106f33dae35cabb6b4dd82a", + "12": "1f7ae3b9ba16e252167ba5a2bbbaeef24511ff7859080650f25b5a4c2be16b39", + "13": "2151eb463c52d02e0afcf14ac70fa34b3084d6ab4441962e0174c9c8368cc44b", + "14": "c6d55fc813f06d2360fcd2ffc34d36b068f3f050d5e2a7922b27a537483c36ac", + "15": "37d6e5c2742eeb8c487180af468857aba3afbdba16b953a00c49a0c40e2da604", + "16": "9af497552263a76787021d52e236d187559bbd05390a97f210bfd38d2b411ec5", + "17": "55637fd7ad6972bb456ea79ed65303467abfb25227a88c317c307b0ef34c16eb", + "18": "5789defb44c76625c7d983fe8ddd7f9285109a4f9d975459dab74dde360e7310", + "19": "3d84fbc6b21c970ed9cdb263874e4ea7efb2bf460a9ce3a6d7308a0da427adb8", + "20": "7852b587fa1294d76f37f1dde0a7fcf9f7f97c2e97c9dfe63209456b064cf3c7", + "21": "b1a6aae79e91cb981f5b5538cc432249139ca83f73f329779d353764af7ce04b", + "22": "c0c9d9d70987146311e00a64d2557299417a1ea93f02f56975e475884058cf31", + "23": "8c0b1ea7bd886cbcdfcb2cbbcb28f711ff20ce1daa1f9195c6c4990a8f227d70", + "24": "c626576ce733b83035ebdd6487087aac272862c2f5a7fe5c876d86ffa21e8997", + "25": "6351fd9ba69e0cb84443c1f80d326322cd4dc1d1cca567bde42ad1cff9a0f692", + "26": "695fe05691c137a14a1ca8af1bb54f7f6e2e82d2483015dcaef4ecd2fc3a613b", + "27": "d1aa07b9a5e52aedd3091a4068ceda7b294791bb1061b3662ddd732831b97416", + "28": "02fc6e9ff163fae5c23e54cfc32c67d46db2ccb3697b14ad722beb9acee8532f", + "29": "3e2a1a89d5e330e1a8b3376e92f90358cd3190669ebd37a5237f93f011e3cdeb", + "30": "db0cd0a6a95bc97a9459b745a23602cfc1c2c92c0bd42872e611d5b44e1b0179", + "31": "9d4a70c525a367ac9b490650e50d12bbd05892de318ffab0ed72390a5e3bee19" + } + }, + "stage_wall_s": { + "digital_downconversion": 0.015340200159698725, + "rrc": 0.06148699996992946, + "preamble_search": 0.049240299966186285, + "timing_recovery": 0.009207499446347356, + "carrier_estimation": 0.012512199580669403, + "phy_header": 0.0005785003304481506, + "carrier_tracking": 0.049880800070241094, + "strict_phy_fec_crc": 0.05299079976975918 + }, + "cprofile": { + "profile_total_s": 0.2820342, + "top_functions": [ + { + "function": "receive_iq_stream", + "file": "video005_receiver.py", + "calls": 1, + "self_s": 0.0239516, + "cumulative_s": 0.2823125 + }, + { + "function": "track_qpsk_body", + "file": "video005_carrier.py", + "calls": 32, + "self_s": 0.0424, + "cumulative_s": 0.0743558 + }, + { + "function": "_freq_domain_conv", + "file": "_signaltools.py", + "calls": 3, + "self_s": 0.0111908, + "cumulative_s": 0.0632502 + }, + { + "function": "oaconvolve", + "file": "_signaltools.py", + "calls": 1, + "self_s": 0.0022263, + "cumulative_s": 0.05457360000000001 + }, + { + "function": "__ua_function__", + "file": "_backend.py", + "calls": 9, + "self_s": 4.22e-05, + "cumulative_s": 0.052000000000000005 + }, + { + "function": "_execute_nD", + "file": "_basic_backend.py", + "calls": 9, + "self_s": 7.04e-05, + "cumulative_s": 0.051928400000000006 + }, + { + "function": "c2cn", + "file": "basic.py", + "calls": 9, + "self_s": 8.520000000000001e-05, + "cumulative_s": 0.0518491 + }, + { + "function": "receive_frame", + "file": "video003_modem.py", + "calls": 32, + "self_s": 0.0015932000000000001, + "cumulative_s": 0.051615600000000005 + }, + { + "function": "search_preamble_grid", + "file": "video005_timing.py", + "calls": 1, + "self_s": 0.0113077, + "cumulative_s": 0.0465666 + }, + { + "function": "", + "file": "~", + "calls": 9, + "self_s": 0.042739900000000004, + "cumulative_s": 0.042739900000000004 + }, + { + "function": "fftn", + "file": "_basic_backend.py", + "calls": 6, + "self_s": 1.29e-05, + "cumulative_s": 0.0314563 + }, + { + "function": "fftconvolve", + "file": "_signaltools.py", + "calls": 2, + "self_s": 0.0007782, + "cumulative_s": 0.030331900000000002 + }, + { + "function": "decode_soft", + "file": "video004_viterbi.py", + "calls": 32, + "self_s": 0.027181200000000003, + "cumulative_s": 0.0286495 + }, + { + "function": "_wrapfunc", + "file": "fromnumeric.py", + "calls": 3215, + "self_s": 0.0026154000000000004, + "cumulative_s": 0.0212126 + }, + { + "function": "ifftn", + "file": "_basic_backend.py", + "calls": 3, + "self_s": 7.6e-06, + "cumulative_s": 0.0204926 + }, + { + "function": "clip", + "file": "fromnumeric.py", + "calls": 3136, + "self_s": 0.0020616000000000002, + "cumulative_s": 0.014409600000000002 + }, + { + "function": "_estimate_phase_frequency", + "file": "video003_modem.py", + "calls": 96, + "self_s": 0.0090726, + "cumulative_s": 0.0111459 + }, + { + "function": "sum", + "file": "fromnumeric.py", + "calls": 3520, + "self_s": 0.0025614, + "cumulative_s": 0.010104400000000001 + }, + { + "function": "_wrapit", + "file": "fromnumeric.py", + "calls": 3170, + "self_s": 0.0034446000000000004, + "cumulative_s": 0.0097902 + }, + { + "function": "resize", + "file": "fromnumeric.py", + "calls": 1, + "self_s": 0.0089808, + "cumulative_s": 0.0090024 + }, + { + "function": "_correct", + "file": "video003_modem.py", + "calls": 96, + "self_s": 0.0086792, + "cumulative_s": 0.0088638 + }, + { + "function": "_fix_shape", + "file": "helper.py", + "calls": 9, + "self_s": 0.0086459, + "cumulative_s": 0.0087046 + }, + { + "function": "correct_pilot_estimate", + "file": "video005_carrier.py", + "calls": 32, + "self_s": 0.0069142000000000006, + "cumulative_s": 0.007163200000000001 + }, + { + "function": "_wrapreduction", + "file": "fromnumeric.py", + "calls": 3520, + "self_s": 0.0029459, + "cumulative_s": 0.0071003 + }, + { + "function": "_apply_conv_mode", + "file": "_signaltools.py", + "calls": 3, + "self_s": 1.94e-05, + "cumulative_s": 0.0066795000000000005 + }, + { + "function": "", + "file": "~", + "calls": 22, + "self_s": 0.0066147, + "cumulative_s": 0.0066197 + }, + { + "function": "xp_copy", + "file": "_array_api.py", + "calls": 3, + "self_s": 9.7e-06, + "cumulative_s": 0.0066021000000000005 + }, + { + "function": "_asarray", + "file": "_array_api.py", + "calls": 3, + "self_s": 1.2100000000000001e-05, + "cumulative_s": 0.0065924 + }, + { + "function": "", + "file": "~", + "calls": 100, + "self_s": 0.0062674, + "cumulative_s": 0.0062674 + }, + { + "function": "wrapped_f", + "file": "_internal.py", + "calls": 9, + "self_s": 1.16e-05, + "cumulative_s": 0.006219400000000001 + }, + { + "function": "reshape", + "file": "fromnumeric.py", + "calls": 4, + "self_s": 8.200000000000001e-06, + "cumulative_s": 0.0061865 + }, + { + "function": "reshape", + "file": "_aliases.py", + "calls": 3, + "self_s": 1.01e-05, + "cumulative_s": 0.0061824 + }, + { + "function": "angle", + "file": "_function_base_impl.py", + "calls": 3392, + "self_s": 0.00351, + "cumulative_s": 0.0048132 + }, + { + "function": "", + "file": "~", + "calls": 3136, + "self_s": 0.0011408, + "cumulative_s": 0.004746500000000001 + }, + { + "function": "", + "file": "~", + "calls": 3970, + "self_s": 0.004726800000000001, + "cumulative_s": 0.004726800000000001 + }, + { + "function": "sample_symbols", + "file": "video005_timing.py", + "calls": 32, + "self_s": 0.0042592, + "cumulative_s": 0.0046917 + }, + { + "function": "estimate_from_preamble", + "file": "video005_carrier.py", + "calls": 32, + "self_s": 0.0001661, + "cumulative_s": 0.0046685 + }, + { + "function": "pad", + "file": "_delegation.py", + "calls": 1, + "self_s": 1.83e-05, + "cumulative_s": 0.0046613 + }, + { + "function": "pad", + "file": "_arraypad_impl.py", + "calls": 1, + "self_s": 2.36e-05, + "cumulative_s": 0.0046429 + }, + { + "function": "_pad_simple", + "file": "_arraypad_impl.py", + "calls": 1, + "self_s": 0.0045547, + "cumulative_s": 0.004569500000000001 + } + ], + "selected_stages": [ + { + "function": "track_qpsk_body", + "file": "video005_carrier.py", + "calls": 32, + "self_s": 0.0424, + "cumulative_s": 0.0743558 + }, + { + "function": "oaconvolve", + "file": "_signaltools.py", + "calls": 1, + "self_s": 0.0022263, + "cumulative_s": 0.05457360000000001 + }, + { + "function": "search_preamble_grid", + "file": "video005_timing.py", + "calls": 1, + "self_s": 0.0113077, + "cumulative_s": 0.0465666 + }, + { + "function": "decode_soft", + "file": "video004_viterbi.py", + "calls": 32, + "self_s": 0.027181200000000003, + "cumulative_s": 0.0286495 + }, + { + "function": "estimate_from_preamble", + "file": "video005_carrier.py", + "calls": 32, + "self_s": 0.0001661, + "cumulative_s": 0.0046685 + }, + { + "function": "refine_timing", + "file": "video005_timing.py", + "calls": 32, + "self_s": 0.0027497000000000003, + "cumulative_s": 0.0044559000000000005 + }, + { + "function": "depuncture_soft", + "file": "punctured_fec.py", + "calls": 32, + "self_s": 0.0010352, + "cumulative_s": 0.0014112 + }, + { + "function": "qpsk_soft", + "file": "video003_modem.py", + "calls": 32, + "self_s": 0.00043200000000000004, + "cumulative_s": 0.00048400000000000006 + }, + { + "function": "parse_packet", + "file": "packet.py", + "calls": 32, + "self_s": 0.00022830000000000002, + "cumulative_s": 0.0003616 + } + ] + } + }, + "awgn": [ + { + "ebn0_db": 0, + "crc_valid_packets": 0, + "exact_packets": 0, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": {} + }, + { + "ebn0_db": 3, + "crc_valid_packets": 1, + "exact_packets": 1, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12" + } + }, + { + "ebn0_db": 6, + "crc_valid_packets": 16, + "exact_packets": 16, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "0": "5311026a6748649bfa20079af68bdb9ec9df2b2f9c376ef7ca879a2a6dfe6f45", + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12", + "2": "fa629eacc85a00b4389cd0d1f78f67c9ecab759cd5ff5b6250234b080610a55f", + "3": "e4323493982a55413f2bc44470daccda128f85d640795470493ad921e9495a96", + "4": "0a39a0f221a12aa8a408edad1256b5d49f179d4e6df1c513db5006f4123725cd", + "5": "1982f5dd5cecb78dce88d4d4c108e7fdc2c769926f5f7efa8fc42ae1232135ea", + "6": "29f1d6c8a17e8de23979600ced9a4a3a1982645c8421fe9029aa0e505dbc1910", + "7": "583b4edcbe415655a77ca8888dcc45d28834ecd18628e53933e7ab1a74211396", + "8": "7080ab484f0f050548e736eda57a0e3a93941e1d3b816c071448ee70fa8a5ea4", + "9": "11bac43be1d14f232b3ac554cf1d7933e2ef5afa091c79f0ddd6ded434340a0d", + "10": "553cab880d7b7ffccc19cf18b034ab9ce52b6589fbed501bf56a9741e874da8e", + "11": "26b2bf0c3b022108466940d6343ac46dd8149a499106f33dae35cabb6b4dd82a", + "12": "1f7ae3b9ba16e252167ba5a2bbbaeef24511ff7859080650f25b5a4c2be16b39", + "13": "2151eb463c52d02e0afcf14ac70fa34b3084d6ab4441962e0174c9c8368cc44b", + "14": "c6d55fc813f06d2360fcd2ffc34d36b068f3f050d5e2a7922b27a537483c36ac", + "15": "37d6e5c2742eeb8c487180af468857aba3afbdba16b953a00c49a0c40e2da604" + } + }, + { + "ebn0_db": 9, + "crc_valid_packets": 16, + "exact_packets": 16, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "0": "5311026a6748649bfa20079af68bdb9ec9df2b2f9c376ef7ca879a2a6dfe6f45", + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12", + "2": "fa629eacc85a00b4389cd0d1f78f67c9ecab759cd5ff5b6250234b080610a55f", + "3": "e4323493982a55413f2bc44470daccda128f85d640795470493ad921e9495a96", + "4": "0a39a0f221a12aa8a408edad1256b5d49f179d4e6df1c513db5006f4123725cd", + "5": "1982f5dd5cecb78dce88d4d4c108e7fdc2c769926f5f7efa8fc42ae1232135ea", + "6": "29f1d6c8a17e8de23979600ced9a4a3a1982645c8421fe9029aa0e505dbc1910", + "7": "583b4edcbe415655a77ca8888dcc45d28834ecd18628e53933e7ab1a74211396", + "8": "7080ab484f0f050548e736eda57a0e3a93941e1d3b816c071448ee70fa8a5ea4", + "9": "11bac43be1d14f232b3ac554cf1d7933e2ef5afa091c79f0ddd6ded434340a0d", + "10": "553cab880d7b7ffccc19cf18b034ab9ce52b6589fbed501bf56a9741e874da8e", + "11": "26b2bf0c3b022108466940d6343ac46dd8149a499106f33dae35cabb6b4dd82a", + "12": "1f7ae3b9ba16e252167ba5a2bbbaeef24511ff7859080650f25b5a4c2be16b39", + "13": "2151eb463c52d02e0afcf14ac70fa34b3084d6ab4441962e0174c9c8368cc44b", + "14": "c6d55fc813f06d2360fcd2ffc34d36b068f3f050d5e2a7922b27a537483c36ac", + "15": "37d6e5c2742eeb8c487180af468857aba3afbdba16b953a00c49a0c40e2da604" + } + } + ], + "lab066_like": { + "parameters": { + "label": "LAB066-LIKE DIAGNOSTIC CHANNEL MODEL", + "measured_band_p95_p05_db": 2.8700698316435833, + "modeled_band_p95_p05_db": 2.870069831644143, + "measured_matched_residual_db": 5.546387388694418, + "filter_delay_samples": 8, + "filter_second_tap": 0.17619435812980375, + "limitation": "FIR ripple and white residual surrogate do not reproduce OTA propagation" + }, + "outcome": { + "crc_valid_packets": 30, + "exact_packets": 30, + "unexpected_or_wrong_packets": 0, + "packet_sha256_by_sequence": { + "0": "5311026a6748649bfa20079af68bdb9ec9df2b2f9c376ef7ca879a2a6dfe6f45", + "1": "2a6964347246f621ecd12f0a574ebefe977d9902dbbb98aae19dd0e3e03b3e12", + "2": "fa629eacc85a00b4389cd0d1f78f67c9ecab759cd5ff5b6250234b080610a55f", + "3": "e4323493982a55413f2bc44470daccda128f85d640795470493ad921e9495a96", + "4": "0a39a0f221a12aa8a408edad1256b5d49f179d4e6df1c513db5006f4123725cd", + "5": "1982f5dd5cecb78dce88d4d4c108e7fdc2c769926f5f7efa8fc42ae1232135ea", + "6": "29f1d6c8a17e8de23979600ced9a4a3a1982645c8421fe9029aa0e505dbc1910", + "7": "583b4edcbe415655a77ca8888dcc45d28834ecd18628e53933e7ab1a74211396", + "8": "7080ab484f0f050548e736eda57a0e3a93941e1d3b816c071448ee70fa8a5ea4", + "9": "11bac43be1d14f232b3ac554cf1d7933e2ef5afa091c79f0ddd6ded434340a0d", + "10": "553cab880d7b7ffccc19cf18b034ab9ce52b6589fbed501bf56a9741e874da8e", + "11": "26b2bf0c3b022108466940d6343ac46dd8149a499106f33dae35cabb6b4dd82a", + "12": "1f7ae3b9ba16e252167ba5a2bbbaeef24511ff7859080650f25b5a4c2be16b39", + "13": "2151eb463c52d02e0afcf14ac70fa34b3084d6ab4441962e0174c9c8368cc44b", + "14": "c6d55fc813f06d2360fcd2ffc34d36b068f3f050d5e2a7922b27a537483c36ac", + "15": "37d6e5c2742eeb8c487180af468857aba3afbdba16b953a00c49a0c40e2da604", + "17": "55637fd7ad6972bb456ea79ed65303467abfb25227a88c317c307b0ef34c16eb", + "18": "5789defb44c76625c7d983fe8ddd7f9285109a4f9d975459dab74dde360e7310", + "20": "7852b587fa1294d76f37f1dde0a7fcf9f7f97c2e97c9dfe63209456b064cf3c7", + "21": "b1a6aae79e91cb981f5b5538cc432249139ca83f73f329779d353764af7ce04b", + "22": "c0c9d9d70987146311e00a64d2557299417a1ea93f02f56975e475884058cf31", + "23": "8c0b1ea7bd886cbcdfcb2cbbcb28f711ff20ce1daa1f9195c6c4990a8f227d70", + "24": "c626576ce733b83035ebdd6487087aac272862c2f5a7fe5c876d86ffa21e8997", + "25": "6351fd9ba69e0cb84443c1f80d326322cd4dc1d1cca567bde42ad1cff9a0f692", + "26": "695fe05691c137a14a1ca8af1bb54f7f6e2e82d2483015dcaef4ecd2fc3a613b", + "27": "d1aa07b9a5e52aedd3091a4068ceda7b294791bb1061b3662ddd732831b97416", + "28": "02fc6e9ff163fae5c23e54cfc32c67d46db2ccb3697b14ad722beb9acee8532f", + "29": "3e2a1a89d5e330e1a8b3376e92f90358cd3190669ebd37a5237f93f011e3cdeb", + "30": "db0cd0a6a95bc97a9459b745a23602cfc1c2c92c0bd42872e611d5b44e1b0179", + "31": "9d4a70c525a367ac9b490650e50d12bbd05892de318ffab0ed72390a5e3bee19" + } + } + }, + "video": { + "source_sha256": "651949922042c223375d40bf8929def97dbb62c6ade651d1f5b08eea47127d5f", + "original_frames": 20, + "scheduled_phy_packets": 117, + "iq_duration_s": 1.99108075, + "preparation_time_s": { + "source_and_h264_encode": 0.3166592998895794, + "reference_h264_diagnostic": 0.02872509998269379, + "outer_fec_and_phy": 0.5966779999434948, + "tx_rrc_and_iq": 0.9229081000667065 + }, + "cases": [ + { + "scenario": "clean", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 0, + "crc_valid_radio_packets": 117, + "radio_candidates": 126, + "exact_crc_packets": true, + "channel_time_s": 1.3050862001255155, + "radio_time_s": 1.0895978999324143, + "video_transport_and_decode_time_s": 0.05674659996293485, + "diagnostic_verification_time_s": 0.011282900115475059, + "radio_realtime_factor": 0.5472394326209091, + "iq_to_decoded_video_realtime_factor": 0.5757398337035547, + "end_to_end_realtime_factor": 2.1534415919196874, + "radio_stage_time_s": { + "digital_downconversion": 0.07972129993140697, + "rrc": 0.2869933000765741, + "preamble_search": 0.21143439994193614, + "timing_recovery": 0.03136729868128896, + "carrier_estimation": 0.04669760027900338, + "phy_header": 0.0019420003518462181, + "carrier_tracking": 0.18077019834890962, + "strict_phy_fec_crc": 0.1834287999663502 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 20, + "displayed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 10.0, + "frame_loss_rate": 0.0, + "p50_frame_age_ms": 101.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 0, + "recovered_fragments": 0, + "unrecoverable_blocks": 0, + "completed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": null, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0010998002253472805, + "video_transport_time_s": 0.0008596989791840315, + "h264_decode_time_s": 0.015707900747656822, + "diagnostic_verification_time_s": 0.011282900115475059 + } + }, + { + "scenario": "random_1", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 1, + "crc_valid_radio_packets": 116, + "radio_candidates": 141, + "exact_crc_packets": true, + "channel_time_s": 1.3720279999542981, + "radio_time_s": 1.0603703998494893, + "video_transport_and_decode_time_s": 0.03350840019993484, + "diagnostic_verification_time_s": 0.009178399806842208, + "radio_realtime_factor": 0.5325602187904681, + "iq_to_decoded_video_realtime_factor": 0.549389471044519, + "end_to_end_realtime_factor": 2.160712065496843, + "radio_stage_time_s": { + "digital_downconversion": 0.06321029993705451, + "rrc": 0.27685060002841055, + "preamble_search": 0.21508700004778802, + "timing_recovery": 0.032752800500020385, + "carrier_estimation": 0.050895600114017725, + "phy_header": 0.0019038999453186989, + "carrier_tracking": 0.17211620067246258, + "strict_phy_fec_crc": 0.1804436994716525 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 20, + "displayed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 10.0, + "frame_loss_rate": 0.0, + "p50_frame_age_ms": 101.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 0, + "recovered_fragments": 1, + "unrecoverable_blocks": 0, + "completed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": null, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0010854995343834162, + "video_transport_time_s": 0.0007491002324968576, + "h264_decode_time_s": 0.014510001055896282, + "diagnostic_verification_time_s": 0.009178399806842208 + } + }, + { + "scenario": "random_5", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 6, + "crc_valid_radio_packets": 111, + "radio_candidates": 218, + "exact_crc_packets": true, + "channel_time_s": 1.3296970999799669, + "radio_time_s": 1.0987815998960286, + "video_transport_and_decode_time_s": 0.02991700009442866, + "diagnostic_verification_time_s": 0.009757599793374538, + "radio_realtime_factor": 0.5518518522646702, + "iq_to_decoded_video_realtime_factor": 0.5668773604437978, + "end_to_end_realtime_factor": 2.1569396921095265, + "radio_stage_time_s": { + "digital_downconversion": 0.07919909991323948, + "rrc": 0.28962199995294213, + "preamble_search": 0.2058348001446575, + "timing_recovery": 0.04587210062891245, + "carrier_estimation": 0.07462249998934567, + "phy_header": 0.0028023002669215202, + "carrier_tracking": 0.1666663009673357, + "strict_phy_fec_crc": 0.16825899947434664 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 17, + "displayed_frame_ids": [ + 0, + 1, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 8.5, + "frame_loss_rate": 0.15000000000000002, + "p50_frame_age_ms": 119.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 1, + "recovered_fragments": 3, + "unrecoverable_blocks": 1, + "completed_frame_ids": [ + 0, + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": null, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0012600007466971874, + "video_transport_time_s": 0.0007416992448270321, + "h264_decode_time_s": 0.01268000015988946, + "diagnostic_verification_time_s": 0.009757599793374538 + } + }, + { + "scenario": "keyframe_double", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 2, + "crc_valid_radio_packets": 115, + "radio_candidates": 157, + "exact_crc_packets": true, + "channel_time_s": 1.300955600105226, + "radio_time_s": 1.1206137998960912, + "video_transport_and_decode_time_s": 0.03323830012232065, + "diagnostic_verification_time_s": 0.009229799965396523, + "radio_realtime_factor": 0.5628168520518775, + "iq_to_decoded_video_realtime_factor": 0.5795104492966505, + "end_to_end_realtime_factor": 2.1551376557798667, + "radio_stage_time_s": { + "digital_downconversion": 0.0840303001459688, + "rrc": 0.2603089001495391, + "preamble_search": 0.25404300005175173, + "timing_recovery": 0.03837479976937175, + "carrier_estimation": 0.05567309889011085, + "phy_header": 0.0023000999353826046, + "carrier_tracking": 0.17712389887310565, + "strict_phy_fec_crc": 0.18060679989866912 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 20, + "displayed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 10.0, + "frame_loss_rate": 0.0, + "p50_frame_age_ms": 101.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 0, + "recovered_fragments": 2, + "unrecoverable_blocks": 0, + "completed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": 0, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0010622995905578136, + "video_transport_time_s": 0.0006959992460906506, + "h264_decode_time_s": 0.014183000894263387, + "diagnostic_verification_time_s": 0.009229799965396523 + } + }, + { + "scenario": "keyframe_triple", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 3, + "crc_valid_radio_packets": 114, + "radio_candidates": 175, + "exact_crc_packets": true, + "channel_time_s": 1.2999681998044252, + "radio_time_s": 1.0848693998996168, + "video_transport_and_decode_time_s": 0.0267470998223871, + "diagnostic_verification_time_s": 0.008013200014829636, + "radio_realtime_factor": 0.544864591704589, + "iq_to_decoded_video_realtime_factor": 0.5582980498013472, + "end_to_end_realtime_factor": 2.133429344553811, + "radio_stage_time_s": { + "digital_downconversion": 0.07771590002812445, + "rrc": 0.28483200003392994, + "preamble_search": 0.20484809996560216, + "timing_recovery": 0.04128570039756596, + "carrier_estimation": 0.06081529823131859, + "phy_header": 0.002697500865906477, + "carrier_tracking": 0.1692413988057524, + "strict_phy_fec_crc": 0.17629390000365674 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 15, + "displayed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 7.5, + "frame_loss_rate": 0.25, + "p50_frame_age_ms": 89.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 1, + "recovered_fragments": 0, + "unrecoverable_blocks": 1, + "completed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": 667, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0008605006150901318, + "video_transport_time_s": 0.0006423997692763805, + "h264_decode_time_s": 0.01099179987795651, + "diagnostic_verification_time_s": 0.008013200014829636 + } + }, + { + "scenario": "corrupt_packet", + "seed": 20260917, + "ebn0_db": 9.0, + "iq_duration_s": 1.99108075, + "scheduled_phy_packets": 117, + "iq_erased_or_corrupted_packets": 1, + "crc_valid_radio_packets": 116, + "radio_candidates": 125, + "exact_crc_packets": true, + "channel_time_s": 1.2968074998352677, + "radio_time_s": 1.0612598001025617, + "video_transport_and_decode_time_s": 0.03217010013759136, + "diagnostic_verification_time_s": 0.009032899979501963, + "radio_realtime_factor": 0.5330069109967347, + "iq_to_decoded_video_realtime_factor": 0.5491640156935639, + "end_to_end_realtime_factor": 2.1227078811219493, + "radio_stage_time_s": { + "digital_downconversion": 0.07513489993289113, + "rrc": 0.2737044000532478, + "preamble_search": 0.21924789994955063, + "timing_recovery": 0.030561200575903058, + "carrier_estimation": 0.04603239893913269, + "phy_header": 0.002058700192719698, + "carrier_tracking": 0.17165479948744178, + "strict_phy_fec_crc": 0.17838370008394122 + }, + "video": { + "input_frames": 20, + "unique_decoded_frames": 20, + "displayed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "displayed_fps": 10.0, + "frame_loss_rate": 0.0, + "p50_frame_age_ms": 101.0, + "p95_frame_age_ms": 167.0, + "max_frame_age_ms": 167, + "stale_drops": 0, + "recovered_fragments": 1, + "unrecoverable_blocks": 0, + "completed_frame_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "wrong_encoded_frames": 0, + "wrong_decoded_frames": 0, + "h264_decoder_errors": 0, + "keyframe_recovery_ms": 0, + "old_frame_replay": false, + "erasure_fec_time_s": 0.0010160997044295073, + "video_transport_time_s": 0.0006977994926273823, + "h264_decode_time_s": 0.014327399199828506, + "diagnostic_verification_time_s": 0.009032899979501963 + } + } + ] + }, + "sample_clock_offset": "NOT MODELED IN VIDEO-005" +} \ No newline at end of file diff --git a/docs/video/VIDEO_005.md b/docs/video/VIDEO_005.md new file mode 100644 index 0000000..c452626 --- /dev/null +++ b/docs/video/VIDEO_005.md @@ -0,0 +1,187 @@ +# VIDEO-005 — приём IQ и проверка видеотракта + +17 сентября 2026 года. Статус: **PARTIAL**. Это программный эксперимент в +`video-development`, без доступа к SDR и без передачи в эфир. Исходная точка — +`95f6640`; история Git сохранена. Измерения и SHA записаны в +`data/processed/video005/benchmark.json` (seed `20260917`). + +## Рабочая цепочка + +Приёмник `receive_iq_stream()` получает одномерный complex IQ и выбранный режим +радиоканала. Он не получает границы пакета, момент выборки, истинный CFO/фазу, +payload или CRC. Цепочка: цифровое смещение несущей → согласованный RRC → поиск +известной преамбулы по всему потоку → дробное уточнение момента → оценка CFO и +фазы по преамбуле → разрешение квадранта QPSK → блочное слежение за фазой тела +по решениям QPSK → PHY/внутренний FEC 2/3/CRC → фрагменты → внешний FEC 8+1 +с усилением опорного блока → сборка H.264 → декодированные кадры. Эталонные +пакеты и пиксели используются после обработки только для подсчёта ошибок. + +Использованы прежние QPSK mapping, RRC, FEC, Viterbi, CRC и видеотранспорт. +Новые поиск преамбулы, восстановление такта и несущей находятся в отдельных +модулях. Порог корреляции 0,5 отсекает наблюдавшиеся боковые пики PRBS7 около +0,4. Формат преамбулы не менялся. `CURRENT PREAMBLE INSUFFICIENT = NO` **только +для проверенной сетки**; это не доказательство устойчивости в эфире. + +## Проверки сигнала + +На одном полном пакете и в комбинированных трёхпакетных потоках проверены +дробные сдвиги `0`, `±0,1`, `±0,25`, `±0,4` символа; CFO `0`, `±1`, `±5`, +`±20`, `±100` Гц; начальная фаза `0`, `±15`, `±45`, `90`, `180` градусов. +Синтетическое фазовое отклонение тела от 0 до 0,35 рад уменьшалось решающим +QPSK-трекером. Все заявленные строгие проверки восстановили исходный пакет. +Дрейф частоты дискретизации не вводился: `SAMPLE CLOCK OFFSET = NOT MODELED IN +VIDEO-005`. + +AWGN, 16 полных пакетов на точку, фиксированная затравка: при `Eb/N0` 0, 3, 6, +9 дБ получено соответственно **0/16, 1/16, 16/16, 16/16** точных CRC-пакетов; +неверных принятых пакетов — 0. Это наблюдение для выбранного потока, не кривая +ошибок или оценка вероятности для любого канала. + +`LAB066-LIKE DIAGNOSTIC CHANNEL MODEL`: медиана измеренной неравномерности +полосы 2,870 дБ и средний показатель matched/residual 5,546 дБ. Суррогат +содержит двухотводный FIR с задержкой 8 отсчётов и вторым коэффициентом +0,176194, а также белый остаток. Он дал **30/32** точных CRC-пакетов, неверных +принятых — 0. Фильтр согласован по одному сводному показателю; модель не +воспроизводит распределение помех, меняющийся канал, аппаратные искажения или +саму запись Lab066. Это не измерение RF-приёма. + +## Скорость и профиль + +Канонический сигнал: 32 полных пакета, 3 886 112 комплексных отсчётов, +8 Мвыб/с, длительность **0,485764 с**, SHA-256 IQ +`d34c4fbba04708180f60ed759cef8bfa9f67827a57ea5ff201d94701bf32afd2`. +Передача 500 ксимв/с, QPSK, inner FEC 2/3. Первый запуск с JIT занял +**1,203 с**. Три прогретых замера `perf_counter()` — 0,281 / 0,299 / 0,268 с; +медиана **0,281 с**, фактор **0,578**, скорость **1,729×** реального времени. +Получено 32/32 пакета с точным SHA и CRC; целевой фактор ≤0,67 выполнен, +предпочтительный ≤0,50 — нет. Эти числа зависят от нагрузки данного ПК. + +В таблице времена этапов взяты из последнего прогретого запуска (0,268 с), +проценты относятся к нему. Числа вызовов взяты из отдельного `cProfile`; +его времена не складываются с таблицей. Копии/буферы перечислены по коду, +точное число выделений NumPy не измерялось. + +| Этап | Время, мс | Доля | Вызовы | Основные выделения/копии | +|---|---:|---:|---:|---| +| Смещение несущей | 15,3 | 5,7% | 1 | Периодический осциллятор и complex64 baseband, по 3,89 млн отсчётов | +| Согласованный RRC | 61,5 | 22,9% | 1 | Выход свёртки; внутренние FFT-буферы SciPy | +| Поиск преамбулы | 49,2 | 18,4% | 1 | Две корреляции/массивы энергии на прореженных фазах | +| Восстановление такта | 9,2 | 3,4% | 32 | Индексы и один complex64-массив символов на кандидат | +| Оценка CFO/фазы/квадранта | 12,5 | 4,7% | 32 | Корректор и исправленные символы на пакет | +| Проверка PHY-заголовка | 0,6 | 0,2% | 32 | Малый массив заголовка | +| Слежение за фазой QPSK | 49,9 | 18,6% | 32 | Одна копия символов пакета и векторы блоков по 64 | +| Строгий PHY/FEC/CRC | 53,0 | 19,8% | 32 | Мягкие решения, depuncturing и рабочие массивы Viterbi | + +В `cProfile` (полный профилированный вызов 0,282 с): слежение QPSK 74,4 мс, +RRC 54,6 мс, поиск 46,6 мс, Viterbi `decode_soft` 28,6 мс, depuncturing +1,41 мс, QPSK soft decisions 0,48 мс и `parse_packet`/CRC 0,36 мс. На чистом +двухсекундном видео внешний FEC занял 1,10 мс, сборка фрагментов 0,86 мс, +декодирование H.264 15,7 мс. Диагностическая сверка пикселей заняла ещё +11,3 мс и исключена из времени рабочего пути. Доли мелких функций из +`cProfile` относятся к 0,282 с; вложенные функции нельзя суммировать. + +## Видео 640×360/10 + +Первые 20 кадров исходного клипа (SHA-256 +`651949922042c223375d40bf8929def97dbb62c6ade651d1f5b08eea47127d5f`) +закодированы PyAV/libx264 в H.264 SHORT_GOP 400k. Создано 117 PHY-пакетов; +длительность IQ 1,991 с. Потери и повреждение вносятся в **IQ-интервалы**, до +приёмника. Возраст отсчитывается от захвата кадра до завершения радиопакета. + +| Сценарий | CRC-пакеты | Кадры/FPS | Потеря кадров | P50/P95/max, мс | Просрочено | Восстановлено FEC | Опорный кадр | +|---|---:|---:|---:|---:|---:|---:|---:| +| Без потерь | 117 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 0 | — | +| 1% IQ-потерь (факт 1/117) | 116 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 1 | — | +| 5% IQ-потерь (факт 6/117) | 111 | 17/20; 8,5 | 15% | 119/167/167 | 1 | 3 | — | +| Два фрагмента опорного кадра | 115 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 2 | 0 мс | +| Три фрагмента опорного кадра | 114 | 15/20; 7,5 | 25% | 89/167/167 | 1 | 0 | 667 мс до следующего | +| Один испорченный IQ-пакет | 116 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 1 | 0 мс | + +Во всех сценариях нет неверного CRC-пакета, неверно собранного или декодированного +кадра и повторного показа старого кадра. После невосстановленной потери +опорного кадра H.264 ждёт следующий опорный кадр. Чистый путь **IQ → +декодированные кадры** имеет фактор **0,576**. Полный офлайн-прогон от чтения +и кодирования источника через формирование IQ и расчёт синтетического канала +до показа имеет фактор **2,153**; его нельзя представлять как скорость одного +приёмника. В этом прогоне 0,317 с заняли чтение/кодирование, 0,597 с — внешний +FEC и PHY передатчика, 0,923 с — формирование IQ, около 1,3 с — канал и около +1,1 с — радиоприём. Сверка эталона в оба фактора не входит. + +## CODE QUALITY AUDIT + +**BEFORE:** рабочие модули `video003_modem`, `video004_viterbi`, +`video004_erasure` и четыре новых модуля `video005_*` — около 968 строк и +34 функции после реализации; радиофактор до короткой чистки в одном прогоне +0,513. Главные затраты: RRC, поиск преамбулы, слежение за фазой и FEC. + +| Проверка или подозрительное место | Где | Почему и категория | Решение | +|---|---|---|---| +| Длина IQ, конечность, параметры режима, границы интерполяции | `video005_receiver.py`, `video005_timing.py` | A: повреждённый вход и выход за буфер | KEEP | +| PHY-заголовок, CRC, возраст, индексы внешнего FEC | `video005_receiver.py`, старые модули | A: обязательные барьеры до видео | KEEP | +| Равенство длин строк матрицы и векторов внешнего FEC | `packet_erasure_fec.py` | A: обрезка `zip()` могла скрыть невозможное состояние | REWRITE с `strict=True` | +| Предварительный PHY-заголовок и повторная проверка в строгом декодере | `video005_receiver.py` | D: длина тела нужна до FEC, строгий разбор обязателен | KEEP | +| Проверка фазовых решений внутри тела | `video005_carrier.py` | E: решения QPSK нужны для слежения | KEEP, блоками по 64 символа | +| Эталонные SHA пакетов и пикселей | `video005_benchmark.py`, `video005_video.py` | C: диагностика не должна решать судьбу кадра | MOVE_TO_DIAGNOSTICS после обработки | +| `complex128` после интерполяции/фазовой коррекции | `video005_timing.py`, `video005_carrier.py` | Лишний объём временных векторов | REWRITE в `complex64` | +| Повторный H.264 decode эталона | `video005_video.py` | C: нужен для точной пиксельной сверки, не для показа | KEEP вне измеряемого пути | +| `LAB066-LIKE`, `cProfile`, графики и подробный JSON | `tools/` | C: офлайн-диагностика | KEEP вне receiver | +| Периодический осциллятор, RRC/FFT-буферы | `video005_receiver.py` | Выделения велики, но RRC быстрее основной цели | KEEP; дальнейшая оптимизация требует отдельного профиля | + +**REMOVED / SIMPLIFIED:** временные векторы символов и фазовой коррекции стали +`complex64`; эталон перестал управлять H.264-декодированием; два `zip()` +внешнего FEC теперь проверяют равенство длин. **MOVED TO TESTS / +DIAGNOSTICS:** точные SHA и сверка пикселей остаются в проверочном коде и +выполняются после рабочего прохода. Неиспользуемых импортов Ruff не обнаружил; +декодер и передатчик не содержат Lab066-ветвей или глобального изменяемого +состояния. Кеш коэффициентов RRC неизменяемый. Большие функции сборки видео +остаются в офлайн-инструменте; их разделение сейчас не даст доказанного выигрыша. + +**AFTER:** радиофактор 0,578 в финальном запуске; ускорение от изменения dtype +не заявляется из-за разброса времени между прогонами. Строгие пакеты, CRC, +числа кадров и сценарные выходы остались теми же. Прошли 178 тестов, +`ruff`, `pyright` (0 ошибок/предупреждений), `git diff --check` и +`video_gate.py`. Защита от неверных пакетов, индексов и просроченного видео +не удалялась. + +Независимый запуск общего `tools/quick_gate.py` дал 1103 пройденных теста, +2 пропуска и один отказ Lab065: в этом worktree отсутствует игнорируемый +исторический вход `data/raw/lab064/tx_metadata.json`. Это ограничение общего +шлюза, а не воспроизведённый отказ VIDEO-005; целевой VIDEO gate прошёл. + +## Решение и границы + +Предварительная политика без автоматического переключения: GOOD — NORMAL +640×360/10 с 8+1 и усилением опорных блоков; DEGRADED — снизить FPS или +увеличить избыточность; POOR — ROBUST 320×240/5; CRITICAL — отключить видео +или резко снизить поток. Пороги потерь не закреплены: один короткий сценарий +5% дал 17/20 кадров, а суррогат Lab066 не заменяет эфир. + +| Итоговое поле | Результат | +|---|---| +| VIDEO-005 STATUS | **PARTIAL**: радиопуть проходит цель, офлайн-генерация медленнее реального времени, есть потери кадров при 5% | +| RAW IQ RECEIVER | **PASS**: 32/32 точных пакета; фактор 0,578 ≤0,67 | +| PREAMBLE ACQUISITION | **PASS** в проверенных потоках | +| TIMING RECOVERY | **PASS** для 0 и ±0,1/0,25/0,4 символа | +| CARRIER FREQUENCY RECOVERY | **PASS** для 0 и ±1/5/20/100 Гц | +| CARRIER PHASE TRACKING | **PASS** в синтетическом тесте изменения фазы | +| QPSK QUADRANT RESOLUTION | **PASS** для 90°/180° и проверенных фаз | +| FEC 2/3 | **PASS**, битовые/пакетные выходы точны | +| OUTER ERASURE FEC | **PASS** при одном и двух стираниях; три не восстановлены | +| 640x360 / 10 FPS END-TO-END | **PARTIAL**: чисто 20/20, при 5% — 17/20; полный офлайн-фактор 2,153 | +| INPUT VIDEO FPS / DISPLAYED FPS / FRAME LOSS RATE | 10 / 10 / 0% чисто; 10 / 8,5 / 15% при 5% IQ-потерь | +| P50 / P95 / MAX FRAME AGE | 101 / 167 / 167 мс чисто; 119 / 167 / 167 мс при 5% | +| RADIO RECEIVER REALTIME FACTOR | 0,578; IQ → декодированные кадры 0,576 в отдельном видеопрогоне | +| END-TO-END VIDEO REALTIME FACTOR | 2,153 от источника через офлайн-канал до кадра | +| RAW IQ DURATION / JIT FIRST RUN TIME / WARM PROCESSING TIME | 0,485764 / 1,203 / медиана 0,281 с | +| MAIN HOT PATH | IQ → RRC → преамбула/такт/несущая → QPSK → FEC/CRC → фрагменты → erasure FEC → H.264 | +| AWGN RESULT | 0/16, 1/16, 16/16, 16/16 точных пакетов при 0/3/6/9 дБ | +| LAB066-LIKE MODEL RESULT | 30/32 точных пакета; диагностический суррогат | +| SAMPLE CLOCK OFFSET | **NOT MODELED IN VIDEO-005** | +| RUFF / PYRIGHT / VIDEO GATE | **PASS / PASS / PASS** | +| BEST NEXT RF CANDIDATE | QPSK 500 ксимв/с, inner 2/3, outer 8+1 с усилением опорного блока, H.264 SHORT_GOP 400k | +| WHY | 20/20 чисто и при одном стирании; нуждается в отдельной проверке устойчивости к 5% и реальным искажениям | +| RF TX / SDR HARDWARE | **0 / NOT ACCESSED** | +| COMMIT / PUSH | **NOT PERFORMED / NOT PERFORMED** | + +STOP перед следующим этапом. RF-испытание требует отдельной постановки и +разрешения; VIDEO-006 не начата. diff --git a/protocol/packet_erasure_fec.py b/protocol/packet_erasure_fec.py index 5737fcf..b769cf1 100644 --- a/protocol/packet_erasure_fec.py +++ b/protocol/packet_erasure_fec.py @@ -252,6 +252,7 @@ def matrix_inverse( for value, pivot_value in zip( augmented[row_index], augmented[pivot_column], + strict=True, ) ] @@ -305,7 +306,7 @@ def _linear_combine( """Combine equal-size byte strings over GF(256), using C-level helpers.""" accumulator = 0 - for coefficient, symbol in zip(coefficients, symbols): + for coefficient, symbol in zip(coefficients, symbols, strict=True): if coefficient == 0: continue translated = symbol.translate( diff --git a/protocol/video004_erasure.py b/protocol/video004_erasure.py index 01a67db..905e57b 100644 --- a/protocol/video004_erasure.py +++ b/protocol/video004_erasure.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field from collections import deque import struct -from typing import Sequence +from typing import Sequence, cast from protocol.packet_erasure_fec import ( InsufficientSymbolsError, _linear_combine, encode_parity_symbols, @@ -212,7 +212,7 @@ class VideoErasureCollector: block.wires[symbol.index] = wire output = [] if not symbol.is_parity and symbol.index not in block.delivered: - item = direct_item + item = cast(Delivery, direct_item) block.delivered.add(symbol.index) if now_ms - item.capture_ms < self.max_age_ms: output.append(item) diff --git a/protocol/video004_viterbi.py b/protocol/video004_viterbi.py index bf0f688..c7da7c9 100644 --- a/protocol/video004_viterbi.py +++ b/protocol/video004_viterbi.py @@ -6,6 +6,8 @@ from __future__ import annotations +from typing import Any, Callable, cast + import numpy as np from protocol import convolutional_fec, punctured_fec @@ -54,8 +56,12 @@ def _trellis(soft_values: np.ndarray, information_bit_count: int, return decoded[:information_bit_count] -COMPILED_TRELLIS = (njit(cache=False, nogil=True, fastmath=False)(_trellis) - if njit is not None else None) +_compiler = cast(Any, njit) # У Numba нет точной типизации фабрики декоратора. +COMPILED_TRELLIS: Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray] | None = ( + cast(Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray], + _compiler(cache=False, nogil=True, fastmath=False)(_trellis)) + if njit is not None else None +) def decode_soft(soft_values: np.ndarray, information_bit_count: int, diff --git a/protocol/video005_carrier.py b/protocol/video005_carrier.py new file mode 100644 index 0000000..e1b4d14 --- /dev/null +++ b/protocol/video005_carrier.py @@ -0,0 +1,68 @@ +"""Оценка несущей по преамбуле и слежение за фазой QPSK-пакета.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import numpy as np + +from protocol.video003_modem import ( + PHY_HEADER_SYMBOLS, + PREAMBLE_SYMBOLS, + _estimate_phase_frequency, + bpsk_symbols, + known_preamble_bits, +) + + +BODY_START = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS +TRACK_BLOCK_SYMBOLS = 64 + + +@dataclass(frozen=True) +class PilotEstimate: + phase_rad: float + frequency_hz: float + phase_step_rad: float + quadrant: int + + +def estimate_from_preamble(symbols: np.ndarray, symbol_rate: int) -> PilotEstimate: + """Оценивает частоту и абсолютную фазу, включая поворот на 90°/180°.""" + if symbols.ndim != 1 or len(symbols) < PREAMBLE_SYMBOLS: + raise ValueError("недостаточно символов известной преамбулы") + known = bpsk_symbols(known_preamble_bits()) + phase, phase_step = _estimate_phase_frequency(symbols[:PREAMBLE_SYMBOLS], known) + return PilotEstimate( + phase, + phase_step * symbol_rate / (2 * math.pi), + phase_step, + round(phase / (math.pi / 2)) % 4, + ) + + +def correct_pilot_estimate(symbols: np.ndarray, estimate: PilotEstimate) -> np.ndarray: + """Убирает оценённые фазу и CFO из всего найденного пакета.""" + indexes = np.arange(len(symbols), dtype=np.float64) + correction = np.exp(-1j * (estimate.phase_rad + estimate.phase_step_rad * indexes)) + return symbols * correction.astype(np.complex64) + + +def track_qpsk_body(symbols: np.ndarray) -> tuple[np.ndarray, float]: + """Причинное слежение: решение строится из текущего QPSK, без эталонных битов.""" + corrected = symbols.copy() + phase_state = 0.0 + errors: list[float] = [] + for start in range(BODY_START, len(symbols), TRACK_BLOCK_SYMBOLS): + stop = min(start + TRACK_BLOCK_SYMBOLS, len(symbols)) + block = symbols[start:stop] * np.exp(-1j * phase_state) + decisions = (np.where(block.real >= 0, 1.0, -1.0) + + 1j * np.where(block.imag >= 0, 1.0, -1.0)).astype(np.complex64) + error = float(np.angle(np.sum(block * np.conj(decisions)))) + error = float(np.clip(error, -0.2, 0.2)) + phase_state += 0.35 * error + corrected[start:stop] = symbols[start:stop] * np.exp(-1j * phase_state) + errors.append(error) + root_mean_square = math.sqrt(sum(error * error for error in errors) / len(errors)) if errors else 0.0 + return corrected, root_mean_square diff --git a/protocol/video005_iq_waveform.py b/protocol/video005_iq_waveform.py new file mode 100644 index 0000000..4d6e79e --- /dev/null +++ b/protocol/video005_iq_waveform.py @@ -0,0 +1,60 @@ +"""Формирование комплексного IQ для программного видеотракта VIDEO-005.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Sequence + +import numpy as np +from scipy.signal import upfirdn + +from protocol.bpsk_radio import root_raised_cosine_taps +from protocol.video003_modem import ( + RRC_ROLLOFF, + RRC_SPAN_SYMBOLS, + SAMPLE_RATE_HZ, + OfflineFrame, + samples_per_symbol, +) + + +SYMBOL_RATE = 500_000 +NOMINAL_CARRIER_HZ = 125_000.0 +GUARD_SAMPLES = 64 + + +@lru_cache(maxsize=2) +def rrc_taps(symbol_rate: int = SYMBOL_RATE) -> np.ndarray: + """Возвращает неизменяемые коэффициенты уже применяемого RRC-фильтра.""" + taps = root_raised_cosine_taps( + RRC_ROLLOFF, samples_per_symbol(symbol_rate), RRC_SPAN_SYMBOLS + ) + taps.flags.writeable = False + return taps + + +def shape_packet( + frame: OfflineFrame, + symbol_rate: int = SYMBOL_RATE, + carrier_hz: float = NOMINAL_CARRIER_HZ, +) -> np.ndarray: + """Формирует один IQ-пакет с защитными паузами, без канальных нарушений.""" + if not np.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2: + raise ValueError("несущая должна лежать внутри полосы дискретизации") + if frame.modulation != "QPSK" or frame.rate != "2/3": + raise ValueError("VIDEO-005 использует QPSK и внутренний FEC 2/3") + shaped = upfirdn(rrc_taps(symbol_rate), frame.symbols, up=samples_per_symbol(symbol_rate)) + envelope = np.pad(shaped, (GUARD_SAMPLES, GUARD_SAMPLES)) + phase = 2 * np.pi * carrier_hz * np.arange(envelope.size) / SAMPLE_RATE_HZ + return (envelope * np.exp(1j * phase)).astype(np.complex64) + + +def shape_stream( + frames: Sequence[OfflineFrame], + symbol_rate: int = SYMBOL_RATE, + carrier_hz: float = NOMINAL_CARRIER_HZ, +) -> np.ndarray: + """Соединяет IQ-пакеты; приёмнику их границы не передаются.""" + if not frames: + raise ValueError("пустой поток") + return np.concatenate(tuple(shape_packet(frame, symbol_rate, carrier_hz) for frame in frames)) diff --git a/protocol/video005_receiver.py b/protocol/video005_receiver.py new file mode 100644 index 0000000..98999b1 --- /dev/null +++ b/protocol/video005_receiver.py @@ -0,0 +1,154 @@ +"""Строгий программный приём QPSK из непрерывного комплексного IQ.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +import time + +import numpy as np +from scipy.signal import oaconvolve + +from protocol import punctured_fec +from protocol.video003_modem import ( + MAX_FULL_PACKET_BYTES, + PHY_HEADER_SYMBOLS, + PREAMBLE_SYMBOLS, + SAMPLE_RATE_HZ, + _decode_phy_header, + receive_frame, + samples_per_symbol, +) +from protocol.video005_carrier import ( + correct_pilot_estimate, + estimate_from_preamble, + track_qpsk_body, +) +from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, rrc_taps +from protocol.video005_timing import refine_timing, sample_symbols, search_preamble_grid + + +@dataclass(frozen=True) +class ReceivedIqPacket: + packet: bytes + payload: bytes + start_sample: float + timing_phase_symbols: float + carrier_offset_hz: float + initial_phase_rad: float + quadrant: int + preamble_score: float + tracking_error_rms_rad: float + + +@dataclass(frozen=True) +class IqStreamResult: + packets: tuple[ReceivedIqPacket, ...] + candidate_count: int + rejected_candidates: int + stage_time_s: dict[str, float] + + +def receive_iq_stream( + iq: np.ndarray, + *, + symbol_rate: int = SYMBOL_RATE, + carrier_hz: float = NOMINAL_CARRIER_HZ, + decoder_backend: str = "numba", +) -> IqStreamResult: + """Получает только IQ и параметры радиорежима, без подсказок передатчика. + + Внешние метаданные сигнала и ожидаемое число пакетов не являются аргументами. + Возвращаются только пакеты, прошедшие PHY/FEC/CRC. + """ + samples_per_symbol_count = samples_per_symbol(symbol_rate) + if not math.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2: + raise ValueError("неверная номинальная несущая") + if decoder_backend == "numba": + from protocol.video004_viterbi import decode_soft as fec_decoder + elif decoder_backend == "legacy": + fec_decoder = punctured_fec.decode_soft + else: + raise ValueError("неизвестный выбранный декодер") + samples = np.asarray(iq) + if samples.ndim != 1 or not np.iscomplexobj(samples): + raise ValueError("приёмнику нужен одномерный комплексный IQ") + if not np.all(np.isfinite(samples)): + raise ValueError("IQ содержит неконечные значения") + stage: dict[str, float] = {} + if samples.size < PREAMBLE_SYMBOLS * samples_per_symbol_count: + return IqStreamResult((), 0, 0, stage) + + started = time.perf_counter() + period = round(SAMPLE_RATE_HZ / abs(carrier_hz)) if carrier_hz else 0 + if carrier_hz and abs(abs(carrier_hz) * period - SAMPLE_RATE_HZ) < 1e-8: + cycle = np.exp(-1j * np.sign(carrier_hz) * 2 * np.pi + * np.arange(period) / period).astype(np.complex64) + oscillator = np.resize(cycle, samples.size) + baseband = samples.astype(np.complex64, copy=False) * oscillator + else: + indexes = np.arange(samples.size, dtype=np.float64) + oscillator = np.exp(-1j * (2 * np.pi * carrier_hz / SAMPLE_RATE_HZ) * indexes) + baseband = samples * oscillator + stage["digital_downconversion"] = time.perf_counter() - started + + started = time.perf_counter() + matched = oaconvolve(baseband, rrc_taps(symbol_rate).astype(np.float32), mode="full") + stage["rrc"] = time.perf_counter() - started + + started = time.perf_counter() + candidates = search_preamble_grid(matched, samples_per_symbol_count) + stage["preamble_search"] = time.perf_counter() - started + maximum_symbols = ( + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + + math.ceil(punctured_fec.coded_bit_count(MAX_FULL_PACKET_BYTES * 8, "2/3") / 2) + ) + stage.update(timing_recovery=0.0, carrier_estimation=0.0, phy_header=0.0, + carrier_tracking=0.0, strict_phy_fec_crc=0.0) + accepted: list[ReceivedIqPacket] = [] + rejected = 0 + for candidate in candidates: + started = time.perf_counter() + refined = refine_timing(matched, candidate, samples_per_symbol_count) + symbols = sample_symbols(matched, refined.start_sample, + samples_per_symbol_count, maximum_symbols) + stage["timing_recovery"] += time.perf_counter() - started + if symbols.size < PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS: + rejected += 1 + continue + started = time.perf_counter() + estimate = estimate_from_preamble(symbols, symbol_rate) + corrected = correct_pilot_estimate(symbols, estimate) + stage["carrier_estimation"] += time.perf_counter() - started + started = time.perf_counter() + packet_bytes = _decode_phy_header( + corrected[PREAMBLE_SYMBOLS:PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS].real + ) + stage["phy_header"] += time.perf_counter() - started + if packet_bytes is None: + rejected += 1 + continue + frame_symbols = ( + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + + math.ceil(punctured_fec.coded_bit_count(packet_bytes * 8, "2/3") / 2) + ) + corrected = corrected[:frame_symbols] + started = time.perf_counter() + tracked, error_rms = track_qpsk_body(corrected) + stage["carrier_tracking"] += time.perf_counter() - started + started = time.perf_counter() + reception = receive_frame( + tracked, "QPSK", "2/3", symbol_rate, + maximum_start=0, fec_decoder=fec_decoder, + ) + stage["strict_phy_fec_crc"] += time.perf_counter() - started + if not reception.crc_pass or reception.packet is None or reception.payload is None: + rejected += 1 + continue + accepted.append(ReceivedIqPacket( + reception.packet, reception.payload, refined.start_sample, + (refined.start_sample % samples_per_symbol_count) / samples_per_symbol_count, + estimate.frequency_hz, estimate.phase_rad, estimate.quadrant, + refined.score, error_rms, + )) + return IqStreamResult(tuple(accepted), len(candidates), rejected, stage) diff --git a/protocol/video005_timing.py b/protocol/video005_timing.py new file mode 100644 index 0000000..d2f4845 --- /dev/null +++ b/protocol/video005_timing.py @@ -0,0 +1,106 @@ +"""Поиск преамбулы и восстановление дробного момента выборки из IQ.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import numpy as np +from scipy.signal import fftconvolve, find_peaks + +from protocol.video003_modem import PREAMBLE_SYMBOLS, bpsk_symbols, known_preamble_bits + + +@dataclass(frozen=True) +class PreambleCandidate: + start_sample: float + score: float + + +def search_preamble_grid( + matched: np.ndarray, + samples_per_symbol: int, + minimum_score: float = 0.5, +) -> tuple[PreambleCandidate, ...]: + """Ищет пилот во всём сигнале, не получая границ кадров от передатчика.""" + if matched.ndim != 1 or samples_per_symbol < 2 or not 0 < minimum_score < 1: + raise ValueError("неверные параметры поиска преамбулы") + known = bpsk_symbols(known_preamble_bits()) + all_peaks: list[PreambleCandidate] = [] + # Две тактовые фазы дают грубый поиск; дробный пик уточняется отдельно. + phase_step = max(1, samples_per_symbol // 2) + for phase in range(0, samples_per_symbol, phase_step): + series = matched[phase::samples_per_symbol] + if series.size < PREAMBLE_SYMBOLS: + continue + correlation = fftconvolve(series, known[::-1].conj(), mode="valid") + power = np.abs(series) ** 2 + running = np.concatenate(([0.0], np.cumsum(power, dtype=np.float64))) + window_power = running[PREAMBLE_SYMBOLS:] - running[:-PREAMBLE_SYMBOLS] + score = np.abs(correlation) / np.sqrt( + np.maximum(window_power * PREAMBLE_SYMBOLS, 1e-18) + ) + peaks, _ = find_peaks( + score, height=minimum_score, distance=PREAMBLE_SYMBOLS // 2 + ) + for index in peaks: + all_peaks.append(PreambleCandidate(float(phase + index * samples_per_symbol), + float(score[index]))) + all_peaks.sort(key=lambda item: item.start_sample) + merged: list[PreambleCandidate] = [] + for candidate in all_peaks: + if merged and candidate.start_sample - merged[-1].start_sample < 2 * samples_per_symbol: + if candidate.score > merged[-1].score: + merged[-1] = candidate + else: + merged.append(candidate) + return tuple(merged) + + +def refine_timing( + matched: np.ndarray, + candidate: PreambleCandidate, + samples_per_symbol: int, +) -> PreambleCandidate: + """Уточняет пик корреляции до доли отсчёта по известной преамбуле.""" + known = bpsk_symbols(known_preamble_bits()) + center = int(candidate.start_sample) + half_phase_step = max(2, samples_per_symbol // 4) + if (center < half_phase_step + 1 + or center + (PREAMBLE_SYMBOLS - 1) * samples_per_symbol + + half_phase_step + 1 >= matched.size): + return candidate + indexes = center + np.arange(PREAMBLE_SYMBOLS) * samples_per_symbol + shifts = range(-half_phase_step - 1, half_phase_step + 2) + scores: list[float] = [float(abs(np.vdot(known, matched[indexes + shift]))) + for shift in shifts] + best = int(np.argmax(scores)) + if best in (0, len(scores) - 1): + fractional = 0.0 + else: + curvature = scores[best - 1] - 2 * scores[best] + scores[best + 1] + fractional = (0.0 if curvature >= -1e-12 else + 0.5 * (scores[best - 1] - scores[best + 1]) / curvature) + fractional = float(np.clip(fractional, -0.5, 0.5)) + return PreambleCandidate(candidate.start_sample + shifts[best] + fractional, + candidate.score) + + +def sample_symbols( + matched: np.ndarray, + start_sample: float, + samples_per_symbol: int, + maximum_symbols: int, +) -> np.ndarray: + """Интерполирует найденные такты без входного oracle timing.""" + if not math.isfinite(start_sample) or start_sample < 0 or maximum_symbols < 1: + raise ValueError("неверные границы выборки символов") + available = int((matched.size - 2 - start_sample) // samples_per_symbol) + 1 + count = min(maximum_symbols, max(0, available)) + if count == 0: + return np.empty(0, dtype=np.complex64) + positions = start_sample + np.arange(count, dtype=np.float64) * samples_per_symbol + lower = np.floor(positions).astype(np.int64) + fraction = positions - lower + return (matched[lower] * (1 - fraction) + + matched[lower + 1] * fraction).astype(np.complex64) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..9c09c24 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,28 @@ +{ + "include": [ + "protocol/packet.py", + "protocol/video_packet.py", + "protocol/bpsk_radio.py", + "protocol/convolutional_fec.py", + "protocol/packet_erasure_fec.py", + "protocol/punctured_fec.py", + "protocol/video003_compact_header.py", + "protocol/video003_modem.py", + "protocol/video004_erasure.py", + "protocol/video004_viterbi.py", + "protocol/video_stream_transport.py", + "protocol/video005_iq_waveform.py", + "protocol/video005_timing.py", + "protocol/video005_carrier.py", + "protocol/video005_receiver.py", + "tools/video005_channel.py", + "tools/video005_video.py", + "tools/video005_benchmark.py", + "tools/video_gate.py", + "tests/test_video005_iq.py", + "tests/test_video005_video.py" + ], + "typeCheckingMode": "basic", + "pythonVersion": "3.13", + "reportMissingImports": "error" +} diff --git a/requirements-video-dev.txt b/requirements-video-dev.txt new file mode 100644 index 0000000..74bbe39 --- /dev/null +++ b/requirements-video-dev.txt @@ -0,0 +1,2 @@ +ruff==0.16.8 +pyright==1.1.414 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..e2e99db --- /dev/null +++ b/ruff.toml @@ -0,0 +1,5 @@ +target-version = "py313" +line-length = 100 + +[lint] +select = ["E4", "E7", "E9", "F", "B", "C4", "SIM"] diff --git a/tests/test_video005_iq.py b/tests/test_video005_iq.py new file mode 100644 index 0000000..85dd026 --- /dev/null +++ b/tests/test_video005_iq.py @@ -0,0 +1,114 @@ +"""VIDEO-005: приём из IQ без границ, фазовых и битовых подсказок.""" + +from __future__ import annotations + +import inspect +import math + +import numpy as np +import pytest + +from protocol.video003_modem import build_frame +from protocol.video005_carrier import BODY_START, track_qpsk_body +from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_packet, shape_stream +from protocol.video005_receiver import receive_iq_stream +from tools.video005_channel import impair + + +@pytest.fixture(scope="module") +def packet_fixture() -> tuple: + frame = build_frame(bytes((index * 37) % 256 for index in range(1012)), + "QPSK", "2/3", sequence_number=7) + return frame, shape_packet(frame) + + +def _exact(iq: np.ndarray, expected: bytes) -> bool: + received = receive_iq_stream(iq, decoder_backend="legacy") + return len(received.packets) == 1 and received.packets[0].packet == expected + + +def test_clean_raw_iq_is_exact(packet_fixture: tuple) -> None: + frame, iq = packet_fixture + result = receive_iq_stream(iq, decoder_backend="legacy") + assert len(result.packets) == 1 + assert result.packets[0].packet == frame.packet + assert result.packets[0].payload == bytes((index * 37) % 256 for index in range(1012)) + assert iq.size / SAMPLE_RATE_HZ > 0 + + +@pytest.mark.parametrize("offset", [0, 0.1, -0.1, 0.25, -0.25, 0.4, -0.4]) +def test_fractional_timing_is_recovered(packet_fixture: tuple, offset: float) -> None: + frame, iq = packet_fixture + baseline = receive_iq_stream(iq, decoder_backend="legacy").packets[0].start_sample + result = receive_iq_stream(impair(iq, timing_offset_symbols=offset), + decoder_backend="legacy") + assert len(result.packets) == 1 + assert result.packets[0].packet == frame.packet + assert abs(result.packets[0].start_sample - baseline - offset * 16) < 0.05 + + +@pytest.mark.parametrize("offset_hz", [0, 1, -1, 5, -5, 20, -20, 100, -100]) +def test_carrier_frequency_is_recovered(packet_fixture: tuple, offset_hz: int) -> None: + frame, iq = packet_fixture + result = receive_iq_stream(impair(iq, cfo_hz=offset_hz), decoder_backend="legacy") + assert len(result.packets) == 1 + assert result.packets[0].packet == frame.packet + assert abs(result.packets[0].carrier_offset_hz - offset_hz) < 0.5 + + +@pytest.mark.parametrize("phase_degrees", [0, 15, -15, 45, -45, 90, 180]) +def test_preamble_resolves_phase_and_quadrant(packet_fixture: tuple, + phase_degrees: int) -> None: + frame, iq = packet_fixture + result = receive_iq_stream(impair(iq, phase_degrees=phase_degrees), + decoder_backend="legacy") + assert len(result.packets) == 1 + assert result.packets[0].packet == frame.packet + error = math.remainder(result.packets[0].initial_phase_rad + - math.radians(phase_degrees), 2 * math.pi) + assert abs(error) < 0.02 + if phase_degrees in (90, 180): + assert result.packets[0].quadrant == phase_degrees // 90 + + +@pytest.mark.parametrize("ebn0_db", [3, 6, 9]) +def test_awgn_never_yields_wrong_crc_packet(packet_fixture: tuple, ebn0_db: int) -> None: + frame, iq = packet_fixture + result = receive_iq_stream(impair(iq, ebn0_db=ebn0_db), decoder_backend="legacy") + assert all(packet.packet == frame.packet for packet in result.packets) + if ebn0_db == 9: + assert len(result.packets) == 1 + + +@pytest.mark.parametrize("settings", [ + {"timing_offset_symbols": 0.4, "cfo_hz": 100, + "phase_degrees": 90, "ebn0_db": 9}, + {"timing_offset_symbols": -0.25, "cfo_hz": -20, + "phase_degrees": -45, "ebn0_db": 9}, +]) +def test_combined_offsets_in_continuous_stream(settings: dict) -> None: + frames = [build_frame(bytes(((index + packet_index) * 37) % 256 + for index in range(1012)), "QPSK", "2/3", + sequence_number=packet_index) + for packet_index in range(3)] + iq = impair(shape_stream(frames), **settings) + result = receive_iq_stream(iq, decoder_backend="legacy") + assert [packet.packet for packet in result.packets] == [frame.packet for frame in frames] + + +def test_qpsk_tracker_reduces_phase_wander_without_truth_input(packet_fixture: tuple) -> None: + frame, _ = packet_fixture + impaired = frame.symbols.copy() + body_count = len(impaired) - BODY_START + impaired[BODY_START:] *= np.exp(1j * np.linspace(0, 0.35, body_count)) + tracked, _ = track_qpsk_body(impaired) + before = np.mean(np.abs(np.angle(impaired[BODY_START:] / frame.symbols[BODY_START:]))) + after = np.mean(np.abs(np.angle(tracked[BODY_START:] / frame.symbols[BODY_START:]))) + assert after < before / 2 + + +def test_receiver_api_has_no_oracle_parameters() -> None: + names = set(inspect.signature(receive_iq_stream).parameters) + assert names == {"iq", "symbol_rate", "carrier_hz", "decoder_backend"} + with pytest.raises(ValueError, match="комплексный IQ"): + receive_iq_stream(np.zeros(100_000), decoder_backend="legacy") diff --git a/tests/test_video005_video.py b/tests/test_video005_video.py new file mode 100644 index 0000000..644f997 --- /dev/null +++ b/tests/test_video005_video.py @@ -0,0 +1,45 @@ +"""Небольшой H.264-контроль IQ → CRC → внешний FEC → показ кадров.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from tools.video005_video import VideoFixture, build_fixture, run_case + + +@pytest.fixture(scope="module") +def synthetic_video() -> VideoFixture: + rows, columns = np.mgrid[0:360, 0:640] + frames = [np.stack(((columns + index * 17) % 256, + (rows + index * 11) % 256, + (columns // 2 + rows // 3 + index * 29) % 256), axis=-1 + ).astype(np.uint8) + for index in range(3)] + return build_fixture(3, pixels_override=frames) + + +def test_clean_h264_from_raw_iq_displays_three_unique_frames( + synthetic_video: VideoFixture, +) -> None: + result = run_case(synthetic_video, "clean", decoder_backend="legacy") + video = result["video"] + assert result["exact_crc_packets"] + assert result["crc_valid_radio_packets"] == len(synthetic_video.transmissions) + assert video["displayed_frame_ids"] == [0, 1, 2] + assert video["displayed_fps"] == 10 + assert video["max_frame_age_ms"] < 300 + assert video["wrong_encoded_frames"] == video["wrong_decoded_frames"] == 0 + assert not video["old_frame_replay"] + + +def test_corrupted_iq_packet_is_erased_before_h264( + synthetic_video: VideoFixture, +) -> None: + result = run_case(synthetic_video, "corrupt_packet", decoder_backend="legacy") + video = result["video"] + assert result["exact_crc_packets"] + assert result["crc_valid_radio_packets"] == len(synthetic_video.transmissions) - 1 + assert video["recovered_fragments"] == 1 + assert video["displayed_frame_ids"] == [0, 1, 2] + assert video["wrong_encoded_frames"] == video["wrong_decoded_frames"] == 0 diff --git a/tools/video005_benchmark.py b/tools/video005_benchmark.py new file mode 100644 index 0000000..347cf3f --- /dev/null +++ b/tools/video005_benchmark.py @@ -0,0 +1,179 @@ +"""Детерминированный контроль IQ-приёмника и полного программного видео.""" + +from __future__ import annotations + +import argparse +import cProfile +import hashlib +import json +from pathlib import Path +import pstats +import statistics +import sys +import time + +import numpy as np + +from protocol.packet import parse_packet +from protocol.video003_modem import build_frame +from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_stream +from protocol.video005_receiver import IqStreamResult, receive_iq_stream +from tools.video005_channel import impair, lab066_like +from tools.video005_video import build_fixture, run_case + + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "data/processed/video005/benchmark.json" +SEED = 20260917 + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _strict_outcomes(result: IqStreamResult, expected: list[bytes]) -> dict: + observed = {parse_packet(item.packet).sequence_number: _sha(item.packet) + for item in result.packets} + reference = {index: _sha(packet) for index, packet in enumerate(expected)} + return { + "crc_valid_packets": len(result.packets), + "exact_packets": sum(observed.get(index) == digest + for index, digest in reference.items()), + "unexpected_or_wrong_packets": sum(index not in reference or digest != reference[index] + for index, digest in observed.items()), + "packet_sha256_by_sequence": observed, + } + + +def _profile(receiver_iq) -> dict: + profiler = cProfile.Profile() + profiler.enable() + receive_iq_stream(receiver_iq) + profiler.disable() + stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime") + raw_stats = vars(stats)["stats"] # Атрибут pstats создаётся динамически. + listing = [{"function": key[2], "file": key[0], "calls": values[1], + "self_s": values[2], "cumulative_s": values[3]} + for key, values in raw_stats.items()] + listing.sort(key=lambda item: item["cumulative_s"], reverse=True) + selected = ("oaconvolve", "search_preamble_grid", "refine_timing", + "estimate_from_preamble", "track_qpsk_body", "qpsk_soft", + "depuncture_soft", "decode_soft", "_trellis", "parse_packet") + stage_functions = [item for item in listing if item["function"] in selected] + return {"profile_total_s": vars(stats)["total_tt"], + "top_functions": listing[:40], "selected_stages": stage_functions} + + +def build_radio_fixture() -> tuple[list[bytes], np.ndarray, np.ndarray]: + """Канонический IQ-вход для 32 полных пакетов; эталон остаётся снаружи RX.""" + frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)), + "QPSK", "2/3", sequence_number=index) + for index in range(32)] + clean_iq = shape_stream(frames) + channel_iq = impair(clean_iq, cfo_hz=20, phase_degrees=15, + timing_offset_symbols=0.1, ebn0_db=9, seed=SEED) + return [frame.packet for frame in frames], clean_iq, channel_iq + + +def run() -> dict: + reference, clean_iq, channel_iq = build_radio_fixture() + duration = len(channel_iq) / SAMPLE_RATE_HZ + signal_sha256 = _sha(channel_iq.tobytes()) + + started = time.perf_counter() + first = receive_iq_stream(channel_iq) + first_time = time.perf_counter() - started + first_outcome = _strict_outcomes(first, reference) + warm_times = [] + warm_results = [] + for _ in range(3): + started = time.perf_counter() + received = receive_iq_stream(channel_iq) + warm_times.append(time.perf_counter() - started) + warm_results.append(received) + if _strict_outcomes(received, reference) != first_outcome: + raise AssertionError("вход IQ дал разные строгие пакеты на повторах") + median_time = statistics.median(warm_times) + profile = _profile(channel_iq) + + awgn_frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)), + "QPSK", "2/3", sequence_number=index) + for index in range(16)] + awgn_iq = shape_stream(awgn_frames) + awgn = [] + for ebn0_db in (0, 3, 6, 9): + received = receive_iq_stream(impair(awgn_iq, ebn0_db=ebn0_db, seed=SEED)) + awgn.append({"ebn0_db": ebn0_db, + **_strict_outcomes(received, [frame.packet for frame in awgn_frames])}) + + diagnostic_iq, diagnostic_parameters = lab066_like(clean_iq, seed=SEED) + diagnostic = receive_iq_stream(diagnostic_iq) + diagnostic_outcome = _strict_outcomes(diagnostic, reference) + + fixture = build_fixture(20) + video_cases = [run_case(fixture, scenario) for scenario in ( + "clean", "random_1", "random_5", "keyframe_double", + "keyframe_triple", "corrupt_packet", + )] + if not all(case["exact_crc_packets"] for case in video_cases): + raise AssertionError("после CRC обнаружен неправильный пакет") + if not all(not case["video"]["wrong_encoded_frames"] + and not case["video"]["wrong_decoded_frames"] + and not case["video"]["old_frame_replay"] for case in video_cases): + raise AssertionError("в видеотракте обнаружены испорченные или повторные кадры") + clean_video = video_cases[0] + return { + "status": ( + "PARTIAL" if (clean_video["end_to_end_realtime_factor"] > 0.67 + or video_cases[2]["video"]["frame_loss_rate"] > 0) + else "PASS" + ), + "seed": SEED, "rf_tx": 0, "sdr_hardware": "NOT ACCESSED", + "radio": { + "packets": len(reference), "iq_samples": len(channel_iq), + "iq_duration_s": duration, "signal_sha256": signal_sha256, + "jit_first_run_s": first_time, "jit_first_outcome": first_outcome, + "warm_times_s": warm_times, "warm_median_s": median_time, + "realtime_factor": median_time / duration, + "realtime_speed": duration / median_time, + "strict_outcome": first_outcome, + "stage_wall_s": warm_results[-1].stage_time_s, + "cprofile": profile, + }, + "awgn": awgn, + "lab066_like": {"parameters": diagnostic_parameters, + "outcome": diagnostic_outcome}, + "video": { + "source_sha256": fixture.source_sha256, + "original_frames": len(fixture.frames), + "scheduled_phy_packets": len(fixture.transmissions), + "iq_duration_s": len(fixture.iq) / SAMPLE_RATE_HZ, + "preparation_time_s": fixture.preparation_time_s, + "cases": video_cases, + }, + "sample_clock_offset": "NOT MODELED IN VIDEO-005", + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--numba-packages", type=Path) + args = parser.parse_args() + if args.numba_packages is not None: + if not args.numba_packages.is_dir(): + parser.error("каталог Numba недоступен") + sys.path.insert(0, str(args.numba_packages.resolve())) + result = run() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"RADIO IQ factor={result['radio']['realtime_factor']:.3f}; " + f"exact={result['radio']['strict_outcome']['exact_packets']}/32") + for case in result["video"]["cases"]: + print(f"{case['scenario']}: CRC={case['crc_valid_radio_packets']}, " + f"displayed={case['video']['unique_decoded_frames']}/20, " + f"radio={case['radio_realtime_factor']:.3f}, " + f"end-to-end={case['end_to_end_realtime_factor']:.3f}") + + +if __name__ == "__main__": + main() diff --git a/tools/video005_channel.py b/tools/video005_channel.py new file mode 100644 index 0000000..7554222 --- /dev/null +++ b/tools/video005_channel.py @@ -0,0 +1,109 @@ +"""Только программные нарушения IQ; эталоны сюда не передаются приёмнику.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +import statistics +from typing import cast + +import numpy as np +from scipy.optimize import brentq +from scipy.signal import lfilter + +from protocol.video003_modem import SAMPLE_RATE_HZ, samples_per_symbol +from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE + + +ROOT = Path(__file__).resolve().parents[1] +LAB066_RESULT = ROOT / "data/processed/lab066/rf_result.json" + + +def impair( + iq: np.ndarray, + *, + timing_offset_symbols: float = 0.0, + cfo_hz: float = 0.0, + phase_degrees: float = 0.0, + ebn0_db: float | None = None, + seed: int = 20260917, + symbol_rate: int = SYMBOL_RATE, +) -> np.ndarray: + """Сдвиг времени, CFO, фаза и AWGN с фиксированной затравкой.""" + if not all(math.isfinite(value) for value in (timing_offset_symbols, cfo_hz, phase_degrees)): + raise ValueError("параметры канала должны быть конечными") + if ebn0_db is not None and not math.isfinite(ebn0_db): + raise ValueError("Eb/N0 должно быть конечным") + transmitted = np.asarray(iq, dtype=np.complex64) + received = np.empty_like(transmitted) + delay = timing_offset_symbols * samples_per_symbol(symbol_rate) + # Ограничиваем рабочие массивы: полная видеозапись занимает миллионы IQ-отсчётов. + random_generator = np.random.default_rng(seed) + sigma = (math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2) + if ebn0_db is not None else 0.0) + for start in range(0, transmitted.size, 1_000_000): + stop = min(start + 1_000_000, transmitted.size) + indexes = np.arange(start, stop, dtype=np.float64) + if delay: + positions = indexes - delay + lower = np.floor(positions).astype(np.int64) + valid = (lower >= 0) & (lower + 1 < transmitted.size) + part = np.zeros(stop - start, dtype=np.complex64) + fraction = positions[valid] - lower[valid] + part[valid] = (transmitted[lower[valid]] * (1 - fraction) + + transmitted[lower[valid] + 1] * fraction) + else: + part = transmitted[start:stop].copy() + if cfo_hz or phase_degrees: + phase = math.radians(phase_degrees) + 2 * math.pi * cfo_hz * indexes / SAMPLE_RATE_HZ + part *= np.exp(1j * phase).astype(np.complex64) + if ebn0_db is not None: + part += (sigma * (random_generator.standard_normal(stop - start) + + 1j * random_generator.standard_normal(stop - start)) + ).astype(np.complex64) + received[start:stop] = part + return received + + +def lab066_like(iq: np.ndarray, *, seed: int = 20260917) -> tuple[np.ndarray, dict]: + """Диагностический суррогат по двум измеренным сводным характеристикам. + + Частотная рябь и добавочный остаток не воспроизводят точную OTA-передачу. + """ + report = json.loads(LAB066_RESULT.read_text(encoding="utf-8")) + rows = report["physical_diagnostics"]["rows"] + ripple_db = statistics.median(row["band_gain_nonuniformity_p95_p05_db"] for row in rows) + useful_to_residual_db = statistics.mean(row["matched_residual_db"] for row in rows) + frequencies = np.linspace(NOMINAL_CARRIER_HZ - 337_500, + NOMINAL_CARRIER_HZ + 337_500, 2048) + delay_samples = 8 + + def modeled_ripple(alpha: float) -> float: + response = np.abs(1 + alpha * np.exp(-2j * np.pi * frequencies + * delay_samples / SAMPLE_RATE_HZ)) + return 20 * math.log10(float(np.percentile(response, 95) + / np.percentile(response, 5))) + + alpha = cast(float, brentq(lambda value: modeled_ripple(value) - ripple_db, + 0.0, 0.8, full_output=False)) + coefficients = np.zeros(delay_samples + 1) + coefficients[0], coefficients[-1] = 1.0, alpha + filtered = cast(np.ndarray, lfilter(coefficients, [1.0], + np.asarray(iq, dtype=np.complex64))) + residual_ratio = 10 ** (useful_to_residual_db / 10) + sigma = math.sqrt(1 / (2 * residual_ratio)) + random_generator = np.random.default_rng(seed) + result = filtered + sigma * ( + random_generator.standard_normal(filtered.size) + + 1j * random_generator.standard_normal(filtered.size) + ) + return result.astype(np.complex64), { + "label": "LAB066-LIKE DIAGNOSTIC CHANNEL MODEL", + "measured_band_p95_p05_db": ripple_db, + "modeled_band_p95_p05_db": modeled_ripple(alpha), + "measured_matched_residual_db": useful_to_residual_db, + "filter_delay_samples": delay_samples, + "filter_second_tap": alpha, + "limitation": "FIR ripple and white residual surrogate do not reproduce OTA propagation", + } diff --git a/tools/video005_video.py b/tools/video005_video.py new file mode 100644 index 0000000..c826034 --- /dev/null +++ b/tools/video005_video.py @@ -0,0 +1,354 @@ +"""VIDEO-005: H.264 через непрерывный IQ, строгий PHY и внешний FEC.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from fractions import Fraction +import hashlib +import math +import random +import time + +import av +from av.error import FFmpegError +import numpy as np + +from protocol import punctured_fec +from protocol.packet import parse_packet +from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame +from protocol.video004_erasure import VideoErasureCollector, decode_source, parse_symbol +from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, rrc_taps, shape_packet +from protocol.video005_receiver import IqStreamResult, receive_iq_stream +from protocol.video_stream_transport import FrameReassembler, FrameType, VideoFrame +from tools.video003_codec import SOURCE, encode_case +from tools.video004_transport import _wire_schedule +from tools.video_compare import source_frames +from tools.video005_channel import impair + + +STREAM_ID = 0x56303535 +FPS = 10 +SYMBOL_RATE = 500_000 + + +@dataclass(frozen=True) +class Transmission: + index: int + start_sample: int + end_sample: int + block_id: int + source_index: int | None + source_frame_id: int | None + source_is_keyframe: bool + + +@dataclass(frozen=True) +class VideoFixture: + iq: np.ndarray + frames: tuple[VideoFrame, ...] + transmissions: tuple[Transmission, ...] + phy_packet_sha256: tuple[str, ...] + clean_pixel_sha256: dict[int, str] + source_sha256: str + encoder_settings: dict + preparation_time_s: dict[str, float] + + +def _clean_pixels(packets: list[bytes]) -> dict[int, str]: + decoder = av.CodecContext.create("h264", "r") + decoder.open() + hashes = {} + for index, wire in enumerate(packets): + packet = av.Packet(wire) + packet.pts = packet.dts = index + packet.time_base = Fraction(1, FPS) + decoded = decoder.decode(packet) + if len(decoded) != 1 or decoded[0].pts != index: + raise AssertionError("исходный H.264 не декодировался точно") + hashes[index] = hashlib.sha256(decoded[0].to_ndarray(format="rgb24").tobytes()).hexdigest() + return hashes + + +def build_fixture(frame_count: int = 20, + pixels_override: list[np.ndarray] | None = None) -> VideoFixture: + """Кодирует исходный клип и передаёт видео по расписанию без oracle для RX.""" + if not 1 <= frame_count <= 20: + raise ValueError("контроль VIDEO-005 ограничен первыми 20 кадрами") + started = time.perf_counter() + pixels = (source_frames(SOURCE, 640, 360, FPS, 0, frame_count / FPS) + if pixels_override is None else pixels_override) + if len(pixels) != frame_count or any(item.shape != (360, 640, 3) for item in pixels): + raise ValueError("ожидаются кадры RGB 640x360 выбранной длины") + codec, encoded = encode_case(pixels, FPS, "SHORT_GOP", 400_000) + if len(encoded) != frame_count: + raise AssertionError("кодек вернул неверное число кадров") + encode_time = time.perf_counter() - started + started = time.perf_counter() + clean_hashes = _clean_pixels(encoded) + reference_decode_time = time.perf_counter() - started + frames = tuple(VideoFrame( + STREAM_ID, index, + FrameType.KEYFRAME if index in codec["keyframe_positions"] else FrameType.DELTA_FRAME, + index * 100, wire, + ) for index, wire in enumerate(encoded)) + + started = time.perf_counter() + schedule, _ = _wire_schedule(list(frames), 8, 1, True, 1) + phy_frames = [build_frame(wire, "QPSK", "2/3", sequence_number=index) + for index, (wire, _, _, _) in enumerate(schedule)] + outer_phy_time = time.perf_counter() - started + + started = time.perf_counter() + chunks: list[np.ndarray] = [] + intervals: list[Transmission] = [] + position = 0 + for index, ((wire, ready_ms, block_id, is_parity), phy) in enumerate( + zip(schedule, phy_frames, strict=True) + ): + ready_sample = math.ceil(ready_ms * SAMPLE_RATE_HZ / 1000) + if ready_sample > position: + chunks.append(np.zeros(ready_sample - position, dtype=np.complex64)) + position = ready_sample + waveform = shape_packet(phy) + start_sample = position + chunks.append(waveform) + position += waveform.size + symbol = parse_symbol(wire) + source = (decode_source(symbol.data, symbol.stream_id, symbol.index, False) + if not is_parity else None) + intervals.append(Transmission( + index, start_sample, position, block_id, + symbol.index if source is not None else None, + source.fragment.frame_id if source is not None else None, + bool(source is not None and source.fragment.frame_type == FrameType.KEYFRAME), + )) + iq = np.concatenate(chunks) + tx_rrc_time = time.perf_counter() - started + source_sha256 = (hashlib.sha256(SOURCE.read_bytes()).hexdigest() + if pixels_override is None else hashlib.sha256( + b"".join(item.tobytes() for item in pixels) + ).hexdigest()) + return VideoFixture( + iq, frames, tuple(intervals), + tuple(hashlib.sha256(frame.packet).hexdigest() for frame in phy_frames), + clean_hashes, source_sha256, + codec["settings"], + {"source_and_h264_encode": encode_time, "reference_h264_diagnostic": reference_decode_time, + "outer_fec_and_phy": outer_phy_time, "tx_rrc_and_iq": tx_rrc_time}, + ) + + +def _loss_indices(fixture: VideoFixture, scenario: str, seed: int) -> tuple[set[int], int | None]: + random_generator = random.Random(seed) + if scenario == "clean": + return set(), None + if scenario in ("random_1", "random_5"): + probability = int(scenario.split("_")[1]) / 100 + return {item.index for item in fixture.transmissions + if random_generator.random() < probability}, None + if scenario in ("keyframe_double", "keyframe_triple"): + count = 2 if scenario == "keyframe_double" else 3 + by_block: dict[int, list[Transmission]] = {} + for item in fixture.transmissions: + if item.source_is_keyframe and item.source_frame_id is not None \ + and item.source_frame_id >= 5: + by_block.setdefault(item.block_id, []).append(item) + selected = next((items for items in by_block.values() if len(items) >= count), None) + if selected is None: + raise ValueError("не найден блок опорного кадра для выбранных потерь") + return {item.index for item in selected[:count]}, selected[0].source_frame_id + if scenario == "corrupt_packet": + selected = next(item for item in fixture.transmissions + if item.source_frame_id is not None and item.source_frame_id >= 2) + return {selected.index}, selected.source_frame_id + raise ValueError("неизвестный сценарий стираний") + + +def _packet_end_ms(packet_start_sample: float, full_packet_bytes: int) -> int: + body_symbols = math.ceil(punctured_fec.coded_bit_count(full_packet_bytes * 8, "2/3") / 2) + symbols = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_symbols + # Начало в согласованном фильтре сдвинуто на длину двух RRC-фильтров минус один. + sample = packet_start_sample - (len(rrc_taps()) - 1) + symbols * 16 + return math.ceil(sample * 1000 / SAMPLE_RATE_HZ) + + +def assemble_video(fixture: VideoFixture, radio: IqStreamResult, + lost_keyframe_id: int | None = None) -> dict: + """Подаёт только CRC-valid payload в FEC, сборщик и настоящий H.264-декодер.""" + collector = VideoErasureCollector(STREAM_ID) + reassembler = FrameReassembler(STREAM_ID, 1012, max_age_ms=300) + decoder = av.CodecContext.create("h264", "r") + decoder.open() + displayed: list[tuple[int, int, int]] = [] + completed_ids: list[int] = [] + completed_data: list[tuple[int, bytes]] = [] + decoded_frames: list[tuple[int, av.VideoFrame]] = [] + decoder_errors = 0 + erasure_time = 0.0 + transport_time = 0.0 + h264_time = 0.0 + synchronized = False + last_displayed_id: int | None = None + seen_ids: set[int] = set() + for packet in radio.packets: + now_ms = _packet_end_ms(packet.start_sample, len(packet.packet)) + started = time.perf_counter() + deliveries = collector.offer(packet.payload, now_ms) + erasure_time += time.perf_counter() - started + for delivery in deliveries: + age = now_ms - delivery.capture_ms + if not 0 <= age < 300: + raise AssertionError("видеофрагмент вышел за срок") + started = time.perf_counter() + complete = reassembler.offer_fragment(replace(delivery.fragment, age_ms=age), now_ms) + transport_time += time.perf_counter() - started + if complete is None: + continue + completed_ids.append(complete.frame_id) + completed_data.append((complete.frame_id, complete.data)) + if complete.frame_type == FrameType.KEYFRAME: + decoder = av.CodecContext.create("h264", "r") + decoder.open() + synchronized = True + elif last_displayed_id is None or complete.frame_id != last_displayed_id + 1: + synchronized = False + if not synchronized: + continue + started = time.perf_counter() + encoded = av.Packet(complete.data) + encoded.pts = encoded.dts = complete.frame_id + encoded.time_base = Fraction(1, FPS) + try: + decoded = decoder.decode(encoded) + except FFmpegError: + h264_time += time.perf_counter() - started + decoder_errors += 1 + synchronized = False + continue + if len(decoded) != 1 or decoded[0].pts != complete.frame_id: + h264_time += time.perf_counter() - started + decoder_errors += 1 + synchronized = False + continue + h264_time += time.perf_counter() - started + decoded_frames.append((complete.frame_id, decoded[0])) + if complete.frame_id in seen_ids: + raise AssertionError("старый кадр повторно показан") + seen_ids.add(complete.frame_id) + displayed.append((complete.frame_id, now_ms, now_ms - complete.capture_ms)) + last_displayed_id = complete.frame_id + collector.finish() + started = time.perf_counter() + wrong_encoded_frames = sum( + not 0 <= frame_id < len(fixture.frames) + or data != fixture.frames[frame_id].data + for frame_id, data in completed_data + ) + wrong_decoded_frames = sum( + hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest() + != fixture.clean_pixel_sha256.get(frame_id) + for frame_id, frame in decoded_frames + ) + diagnostic_time = time.perf_counter() - started + ages = [age for _, _, age in displayed] + recovery_ms = None + if lost_keyframe_id is not None: + if lost_keyframe_id in seen_ids: + recovery_ms = 0 + else: + later = next((arrival - fixture.frames[lost_keyframe_id].capture_ms + for frame_id, arrival, _ in displayed + if frame_id > lost_keyframe_id + and fixture.frames[frame_id].frame_type == FrameType.KEYFRAME), None) + recovery_ms = later + return { + "input_frames": len(fixture.frames), + "unique_decoded_frames": len(displayed), + "displayed_frame_ids": [frame_id for frame_id, _, _ in displayed], + "displayed_fps": len(displayed) * FPS / len(fixture.frames), + "frame_loss_rate": 1 - len(displayed) / len(fixture.frames), + "p50_frame_age_ms": float(np.percentile(ages, 50)) if ages else None, + "p95_frame_age_ms": float(np.percentile(ages, 95)) if ages else None, + "max_frame_age_ms": max(ages) if ages else None, + "stale_drops": collector.stale_rejections + reassembler.expired_frames, + "recovered_fragments": collector.parity_recoveries, + "unrecoverable_blocks": collector.unrecoverable_blocks, + "completed_frame_ids": completed_ids, + "wrong_encoded_frames": wrong_encoded_frames, + "wrong_decoded_frames": wrong_decoded_frames, + "h264_decoder_errors": decoder_errors, + "keyframe_recovery_ms": recovery_ms, + "old_frame_replay": displayed != sorted(displayed, key=lambda item: item[0]), + "erasure_fec_time_s": erasure_time, + "video_transport_time_s": transport_time, + "h264_decode_time_s": h264_time, + "diagnostic_verification_time_s": diagnostic_time, + } + + +def run_case( + fixture: VideoFixture, + scenario: str = "clean", + *, + seed: int = 20260917, + ebn0_db: float = 9.0, + decoder_backend: str = "numba", +) -> dict: + """Потери создаются в IQ; приёмник получает только повреждённый поток.""" + selected, lost_keyframe_id = _loss_indices(fixture, scenario, seed) + started = time.perf_counter() + iq = impair(fixture.iq, cfo_hz=20, phase_degrees=15, + ebn0_db=ebn0_db, seed=seed) + if selected: + random_generator = np.random.default_rng(seed + 1) + for index in selected: + item = fixture.transmissions[index] + if scenario == "corrupt_packet": + sigma = 2.0 + count = item.end_sample - item.start_sample + iq[item.start_sample:item.end_sample] = sigma * ( + random_generator.standard_normal(count) + + 1j * random_generator.standard_normal(count) + ) + else: + iq[item.start_sample:item.end_sample] = 0 + channel_time = time.perf_counter() - started + started = time.perf_counter() + radio = receive_iq_stream(iq, decoder_backend=decoder_backend) + radio_time = time.perf_counter() - started + started = time.perf_counter() + video = assemble_video(fixture, radio, lost_keyframe_id) + video_time = time.perf_counter() - started + video_pipeline_time = video_time - video["diagnostic_verification_time_s"] + sequence_numbers = [parse_packet(packet.packet).sequence_number for packet in radio.packets] + # Сравнение с эталоном только после CRC и видео-декодирования. + exact_packets = len(set(sequence_numbers)) == len(sequence_numbers) and all( + number < len(fixture.phy_packet_sha256) + for number in sequence_numbers + ) and all( + hashlib.sha256(packet.packet).hexdigest() == fixture.phy_packet_sha256[number] + for packet, number in zip(radio.packets, sequence_numbers, strict=True) + ) + return { + "scenario": scenario, "seed": seed, "ebn0_db": ebn0_db, + "iq_duration_s": len(iq) / SAMPLE_RATE_HZ, + "scheduled_phy_packets": len(fixture.transmissions), + "iq_erased_or_corrupted_packets": len(selected), + "crc_valid_radio_packets": len(radio.packets), + "radio_candidates": radio.candidate_count, + "exact_crc_packets": exact_packets, + "channel_time_s": channel_time, "radio_time_s": radio_time, + "video_transport_and_decode_time_s": video_pipeline_time, + "diagnostic_verification_time_s": video["diagnostic_verification_time_s"], + "radio_realtime_factor": radio_time / (len(iq) / SAMPLE_RATE_HZ), + "iq_to_decoded_video_realtime_factor": ( + radio_time + video_pipeline_time + ) / (len(iq) / SAMPLE_RATE_HZ), + "end_to_end_realtime_factor": ( + sum(duration for stage, duration in fixture.preparation_time_s.items() + if stage != "reference_h264_diagnostic") + + channel_time + radio_time + video_pipeline_time + ) / (len(iq) / SAMPLE_RATE_HZ), + "radio_stage_time_s": radio.stage_time_s, + "video": video, + } diff --git a/tools/video_gate.py b/tools/video_gate.py index c335d08..97b7874 100644 --- a/tools/video_gate.py +++ b/tools/video_gate.py @@ -1,4 +1,4 @@ -"""VIDEO-001/002/003: проверки без данных аппаратных лабораторий и без RF.""" +"""VIDEO-001–005: проверка программного видеотракта без аппаратных лабораторий и RF.""" from __future__ import annotations @@ -34,6 +34,9 @@ MODULES = ( "tools.video004_viterbi_benchmark", "tools.video004_longrun", "tools.video004_pipeline_benchmark", "tools.video004_keyframe", "tools.video004_seed_sweep", + "protocol.video005_iq_waveform", "protocol.video005_timing", + "protocol.video005_carrier", "protocol.video005_receiver", + "tools.video005_channel", "tools.video005_video", "tools.video005_benchmark", ) TESTS = ( "tests/test_packet.py", "tests/test_bpsk_radio.py", @@ -45,6 +48,18 @@ TESTS = ( "tests/test_video004_transport.py", "tests/test_video004_pipeline.py", "tests/test_video004_keyframe.py", + "tests/test_video005_iq.py", "tests/test_video005_video.py", +) +STATIC_PATHS = ( + "protocol/packet.py", "protocol/video_packet.py", "protocol/bpsk_radio.py", + "protocol/convolutional_fec.py", "protocol/packet_erasure_fec.py", + "protocol/video_stream_transport.py", "protocol/punctured_fec.py", "protocol/video003_modem.py", + "protocol/video003_compact_header.py", "protocol/video004_viterbi.py", + "protocol/video004_erasure.py", "protocol/video005_iq_waveform.py", + "protocol/video005_timing.py", "protocol/video005_carrier.py", + "protocol/video005_receiver.py", "tools/video005_channel.py", + "tools/video005_video.py", "tools/video005_benchmark.py", "tools/video_gate.py", + "tests/test_video005_iq.py", "tests/test_video005_video.py", ) ARTIFACTS = { "video001_160": "data/processed/video001/comparison_traffic_160k.json", @@ -66,6 +81,7 @@ ARTIFACTS = { "pipeline004": "data/processed/video004/pipeline_benchmark.json", "keyframe004": "data/processed/video004/keyframe_recovery.json", "seed_sweep004": "data/processed/video004/normal_seed_sweep.json", + "benchmark005": "data/processed/video005/benchmark.json", } FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhackrf"} @@ -73,6 +89,8 @@ FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhac def _check_imports_and_hardware_boundary() -> None: for name in MODULES: module = importlib.import_module(name) + if module.__file__ is None: + raise AssertionError(f"у модуля нет исходного файла: {name}") source_path = Path(module.__file__).resolve() if not source_path.is_relative_to(ROOT.resolve()): raise AssertionError(f"локальный модуль вне worktree: {name}") @@ -156,9 +174,9 @@ def _check_results(documents: dict[str, dict]) -> None: for case in transport["cases"]: if not case["no_old_frame_replay"]: raise AssertionError("повторно показан старый кадр") - if case["p95_end_to_end_age_ms"] is not None: - if case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]: - raise AssertionError("P95 возраста больше максимума") + if (case["p95_end_to_end_age_ms"] is not None + and case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]): + raise AssertionError("P95 возраста больше максимума") selected_clean = [case for case in transport["cases"] if case["codec_profile"] == "SHORT_GOP_400K" and case["link"] == "QPSK_500_R23_1012_0MS" @@ -171,7 +189,7 @@ def _check_results(documents: dict[str, dict]) -> None: "benchmark_manifest", "benchmark_before", "benchmark_after", "transport004", "interleaving004", "viterbi004", "long004", "pipeline_manifest004", "pipeline004", "keyframe004", - "seed_sweep004")): + "seed_sweep004", "benchmark005")): raise AssertionError("в результатах найден ненулевой RF TX") from tools.video004_benchmark import load_fixture, run as benchmark_run @@ -239,35 +257,101 @@ def _check_results(documents: dict[str, dict]) -> None: or sum(x["displayed_frames"] for x in boosted_double) != 120): raise AssertionError("защита опорного кадра изменила результат") + benchmark005 = documents["benchmark005"] + saved_radio = benchmark005["radio"] + if (saved_radio["packets"] != 32 + or saved_radio["strict_outcome"]["exact_packets"] != 32 + or saved_radio["strict_outcome"]["unexpected_or_wrong_packets"] != 0 + or saved_radio["realtime_factor"] > 0.67): + raise AssertionError("сохранённый IQ-контроль VIDEO-005 не прошёл") + cases = {case["scenario"]: case for case in benchmark005["video"]["cases"]} + if (len(cases) != 6 or cases["clean"]["video"]["unique_decoded_frames"] != 20 + or cases["random_1"]["video"]["unique_decoded_frames"] != 20 + or cases["keyframe_double"]["video"]["unique_decoded_frames"] != 20 + or cases["corrupt_packet"]["video"]["unique_decoded_frames"] != 20 + or any(not case["exact_crc_packets"] or case["video"]["wrong_decoded_frames"] + or case["video"]["old_frame_replay"] for case in cases.values())): + raise AssertionError("видеорезультаты VIDEO-005 нарушают строгий контроль") + from tools.video005_benchmark import _strict_outcomes, build_radio_fixture + from protocol.video005_receiver import receive_iq_stream + reference005, _, receiver_iq = build_radio_fixture() + if hashlib.sha256(receiver_iq.tobytes()).hexdigest() != saved_radio["signal_sha256"]: + raise AssertionError("IQ-сигнал VIDEO-005 изменился") + started = time.perf_counter() + current005 = receive_iq_stream(receiver_iq) + elapsed005 = time.perf_counter() - started + current_outcome = _strict_outcomes(current005, reference005) + current_outcome["packet_sha256_by_sequence"] = { + str(index): digest + for index, digest in current_outcome["packet_sha256_by_sequence"].items() + } + if (current_outcome != saved_radio["strict_outcome"] + or elapsed005 / saved_radio["iq_duration_s"] > 0.67): + raise AssertionError("строгий IQ-приём VIDEO-005 или его скорость изменились") + from tools.video005_video import build_fixture, run_case + fixture005 = build_fixture(20) + if (fixture005.source_sha256 != benchmark005["video"]["source_sha256"] + or len(fixture005.transmissions) != benchmark005["video"]["scheduled_phy_packets"]): + raise AssertionError("видеовход VIDEO-005 изменился") + for scenario in ("clean", "random_5"): + actual = run_case(fixture005, scenario) + saved = cases[scenario] + if (actual["crc_valid_radio_packets"] != saved["crc_valid_radio_packets"] + or actual["exact_crc_packets"] != saved["exact_crc_packets"] + or any(actual["video"][key] != saved["video"][key] for key in ( + "displayed_frame_ids", "recovered_fragments", "stale_drops", + "wrong_encoded_frames", "wrong_decoded_frames", "old_frame_replay", + ))): + raise AssertionError(f"повторная сборка видео изменила результат: {scenario}") + + +def _run_static_gates(environment: dict[str, str]) -> None: + commands = ( + [sys.executable, "-B", "-m", "ruff", "check", *STATIC_PATHS], + [sys.executable, "-B", "-m", "pyright", "--project", "pyrightconfig.json"], + ["git", "diff", "--check"], + ) + for command in commands: + completed = subprocess.run(command, cwd=ROOT, env=environment, check=False) + if completed.returncode != 0: + raise SystemExit(f"статический шлюз завершился ошибкой: {command[0:4]}") + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--numba-packages", type=Path) + parser.add_argument("--dev-packages", type=Path) args = parser.parse_args() if args.numba_packages is not None: if not args.numba_packages.is_dir(): parser.error("каталог локальных пакетов Numba отсутствует") sys.path.insert(0, str(args.numba_packages.resolve())) + if args.dev_packages is not None and not args.dev_packages.is_dir(): + parser.error("каталог dev-зависимостей отсутствует") _check_imports_and_hardware_boundary() documents = _load_artifacts() _check_results(documents) - output_root = (ROOT / "data/processed/video003").resolve() + output_root = (ROOT / "data/processed/video005").resolve() if not output_root.is_relative_to(ROOT.resolve()): raise AssertionError("временный каталог вне worktree") environment = os.environ.copy() environment["PYTHONDONTWRITEBYTECODE"] = "1" if args.numba_packages is not None: environment["PYTHONPATH"] = str(args.numba_packages.resolve()) + os.pathsep + environment.get("PYTHONPATH", "") + if args.dev_packages is not None: + environment["PYTHONPATH"] = (str(args.dev_packages.resolve()) + os.pathsep + + environment.get("PYTHONPATH", "")) + _run_static_gates(environment) with tempfile.TemporaryDirectory(prefix="video_gate_pytest_", dir=output_root) as name: temporary = Path(name).resolve() if not temporary.is_relative_to(output_root): - raise AssertionError("временный каталог вне результата VIDEO-003") + raise AssertionError("временный каталог вне результата VIDEO-005") command = [sys.executable, "-B", "-m", "pytest", *TESTS, "-q", "-p", "no:cacheprovider", "--basetemp", str(temporary)] completed = subprocess.run(command, cwd=ROOT, env=environment, check=False) if completed.returncode != 0: raise SystemExit(completed.returncode) - print("VIDEO-SPECIFIC GATE: PASS; результаты, импорты, аппаратная граница и тесты") + print("VIDEO-SPECIFIC GATE: PASS; IQ, видео, ruff, pyright, git diff, тесты и RF-граница") if __name__ == "__main__":