tramex_tools/interface/parser/
parser_phy.rs

1//! PHY layer parser for PDSCH/PUSCH traces
2
3use super::ParsingError;
4use super::hex_extractor::extract_binary_from_lines;
5use crate::data::{AdditionalInfos, Trace};
6use crate::interface::association::TraceRelation;
7use crate::interface::{layer::Layer, types::Direction};
8
9use super::FileParser;
10
11/// PHY channel type for resource grid visualization
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum PHYChannelType {
14    /// Physical Downlink Shared Channel
15    PDSCH,
16    /// Physical Uplink Shared Channel
17    PUSCH,
18    /// Physical Uplink Control Channel
19    PUCCH,
20    /// Physical Downlink Control Channel
21    PDCCH,
22    /// Physical Random Access Channel
23    PRACH,
24    /// Other PHY channel (not displayed in resource grid)
25    Other,
26}
27
28/// Channel-specific data extracted from PHY traces
29#[derive(Debug, Clone)]
30pub enum PHYChannelData {
31    /// PDCCH scheduling info (DCI 0_1 or 1_1)
32    Pdcch {
33        /// DCI format (e.g. "0_1", "1_1")
34        dci: String,
35        /// HARQ process number
36        harq_process: Option<u8>,
37        /// New Data Indicator
38        ndi: Option<u8>,
39        /// Redundancy version index
40        rv_idx: Option<u8>,
41        /// HARQ feedback timing (DCI 1_1 only)
42        harq_feedback_timing: Option<u8>,
43    },
44    /// PDSCH downlink shared channel data
45    Pdsch {
46        /// Retransmission count
47        retx: Option<u8>,
48        /// Redundancy version index
49        rv_idx: Option<u8>,
50    },
51    /// PUSCH uplink shared channel data
52    Pusch {
53        /// Retransmission count
54        retx: Option<u8>,
55        /// Redundancy version index
56        rv_idx: Option<u8>,
57        /// CRC result (true = OK, false = KO)
58        crc: Option<bool>,
59    },
60    /// PUCCH uplink control channel data
61    Pucch {
62        /// PUCCH format (1, 2, etc.)
63        format: Option<u8>,
64        /// ACK/NACK (true = ACK, i.e. value != 0)
65        ack: Option<bool>,
66    },
67    /// No channel-specific data
68    None,
69}
70
71/// PHY layer information extracted from trace lines
72#[derive(Debug, Clone)]
73pub struct PHYInfos {
74    /// Direction (UL or DL)
75    pub direction: Direction,
76    /// Channel type (PDSCH, PUSCH, etc.)
77    pub channel_type: PHYChannelType,
78    /// Frame number
79    pub frame: u16,
80    /// Slot number within frame
81    pub slot: u8,
82    /// PRB start position
83    pub prb_start: u16,
84    /// PRB length (number of PRBs)
85    pub prb_length: u16,
86    /// Symbol start position within slot
87    pub symb_start: u8,
88    /// Symbol length (number of symbols)
89    pub symb_length: u8,
90    /// HARQ process number (0-15 typically)
91    pub harq: Option<u8>,
92    /// True if harq=si (MIB/SIB carry, not a real HARQ process)
93    pub harq_si: bool,
94    /// Channel-specific parsed data
95    pub channel_data: PHYChannelData,
96}
97
98/// PHY layer parser
99pub struct PHYParser;
100
101impl PHYParser {
102    /// Parse PHY layer traces
103    fn parse_lines(lines: &[String]) -> Vec<String> {
104        lines.to_vec()
105    }
106}
107
108impl FileParser for PHYParser {
109    fn parse_additional_infos(lines: &[String]) -> Result<AdditionalInfos, ParsingError> {
110        if lines.is_empty() {
111            return Ok(AdditionalInfos::None);
112        }
113
114        let first_line = &lines[0];
115
116        // Determine direction from the line
117        let direction = if first_line.contains(" DL ") {
118            Direction::DL
119        } else if first_line.contains(" UL ") {
120            Direction::UL
121        } else {
122            Direction::DL // Default to DL
123        };
124
125        // Try to parse PHY info (pass all lines for multi-line PDCCH parsing)
126        match parse_phy_lines(lines, direction) {
127            Some(phy_infos) => {
128                // Store info for PDCCH/PDSCH/PUSCH/PUCCH/PRACH channels
129                if matches!(
130                    phy_infos.channel_type,
131                    PHYChannelType::PDCCH
132                        | PHYChannelType::PDSCH
133                        | PHYChannelType::PUSCH
134                        | PHYChannelType::PUCCH
135                        | PHYChannelType::PRACH
136                ) {
137                    Ok(AdditionalInfos::PHYInfos(phy_infos))
138                } else {
139                    Ok(AdditionalInfos::None)
140                }
141            }
142            None => Ok(AdditionalInfos::None),
143        }
144    }
145
146    fn parse(lines: &[String]) -> Result<Trace, ParsingError> {
147        let additional_infos = match Self::parse_additional_infos(lines) {
148            Ok(m) => m,
149            Err(e) => {
150                return Err(e);
151            }
152        };
153
154        let text = Self::parse_lines(lines);
155
156        let binary = extract_binary_from_lines(lines);
157
158        // Parse timestamp from first line
159        let timestamp = if let Some(first_line) = lines.first() {
160            super::parse_timestamp(first_line)?
161        } else {
162            0
163        };
164
165        let trace = Trace {
166            timestamp,
167            layer: Layer::PHY,
168            additional_infos,
169            text: Some(text),
170            binary,
171            relation: TraceRelation::default(),
172        };
173        Ok(trace)
174    }
175}
176
177/// Parse a PHY layer trace line and extract RB & HARQ information
178/// Accepts all lines of a PHY trace (first line + optional indented continuation
179/// lines for PDCCH). Extracts frame/slot, PRB, symbol, HARQ, and channel-specific data.
180///
181/// Example trace formats:
182/// - 5G PDSCH: `10:32:34.715 [PHY] DL 0001 01 4601  431.16 PDSCH: harq=0 prb=50 symb=1:13 k1=12...`
183/// - 4G PDSCH: `10:37:50.654 [PHY] DL 0001 01 003d   421.0 PDSCH: harq=0 k1=4 prb=23:2...` (no symb)
184///
185/// # Arguments
186/// * `lines` - All lines of the PHY trace (first line + indented fields)
187/// * `direction` - Direction (UL/DL) parsed from the trace header
188///
189/// # Returns
190/// * `Some(PHYInfos)` for handled channels (PDCCH, PDSCH, PUSCH, PUCCH, PRACH)
191/// * `None` for other PHY channels or if parsing fails
192pub fn parse_phy_lines(lines: &[String], direction: Direction) -> Option<PHYInfos> {
193    if lines.is_empty() {
194        return None;
195    }
196    let first_line = &lines[0];
197
198    // Detect channel type from first line
199    let channel_type = if first_line.contains("PDSCH:") {
200        PHYChannelType::PDSCH
201    } else if first_line.contains("PUSCH:") {
202        PHYChannelType::PUSCH
203    } else if first_line.contains("PUCCH:") {
204        PHYChannelType::PUCCH
205    } else if first_line.contains("PDCCH:") {
206        PHYChannelType::PDCCH
207    } else if first_line.contains("PRACH:") {
208        PHYChannelType::PRACH
209    } else {
210        PHYChannelType::Other
211    };
212
213    // Parse frame and slot from the "frame.slot" field (e.g., "421.0")
214    let (frame, slot) = parse_frame_slot(first_line)?;
215
216    // Parse prb field — PDCCH has no prb= on first line
217    let (prb_start, prb_length) = if matches!(channel_type, PHYChannelType::PDCCH) {
218        (0, 0)
219    } else {
220        parse_prb(first_line)?
221    };
222
223    // Parse symb field — if missing, assume full slot (0:14)
224    let (symb_start, symb_length) = parse_symb(first_line).unwrap_or((0, 14));
225
226    // Parse harq field: "harq=2" -> (Some(2), false), "harq=si" -> (None, true)
227    let (harq, harq_si) = parse_harq(first_line);
228
229    // Parse channel-specific data
230    let channel_data = match channel_type {
231        PHYChannelType::PDCCH => parse_pdcch_data(lines),
232        PHYChannelType::PDSCH => parse_pdsch_data(first_line),
233        PHYChannelType::PUSCH => parse_pusch_data(first_line),
234        PHYChannelType::PUCCH => parse_pucch_data(first_line),
235        _ => PHYChannelData::None,
236    };
237
238    Some(PHYInfos {
239        direction,
240        channel_type,
241        frame,
242        slot,
243        prb_start,
244        prb_length,
245        symb_start,
246        symb_length,
247        harq,
248        harq_si,
249        channel_data,
250    })
251}
252
253/// Convenience wrapper for single-line parsing (used in tests)
254pub fn parse_phy_line(line: &str, direction: Direction) -> Option<PHYInfos> {
255    parse_phy_lines(&[line.to_string()], direction)
256}
257
258/// Parse the frame.slot field from the trace line
259///
260/// The frame.slot appears after the UE ID fields, e.g.:
261/// `[PHY] DL 0001 01 003d   421.0 PDSCH:`
262///                                 ^^^^^
263fn parse_frame_slot(line: &str) -> Option<(u16, u8)> {
264    // Look for a pattern like "  421.0 " or "  431.16 "
265    // The frame.slot is typically after the layer/direction/UE fields
266
267    // Split by whitespace and look for a decimal number that looks like frame.slot
268    let parts: Vec<&str> = line.split_whitespace().collect();
269
270    for part in &parts {
271        // Check if part contains a dot and both parts are numeric
272        if let Some(dot_pos) = part.find('.') {
273            let frame_part = &part[..dot_pos];
274            let slot_part = &part[dot_pos + 1..];
275
276            // Make sure there's no trailing punctuation on slot_part
277            let slot_part: String = slot_part.chars().take_while(|c| c.is_ascii_digit()).collect();
278
279            if let (Ok(frame), Ok(slot)) = (frame_part.parse::<u16>(), slot_part.parse::<u8>()) {
280                // Sanity check: frame < 1024 (0-1023), slot < 20
281                if slot < 20 {
282                    return Some((frame, slot));
283                }
284            }
285        }
286    }
287
288    None
289}
290
291/// Parse the harq field from the trace line
292///
293/// Format: "harq=2" -> (Some(2), false), "harq=si" -> (None, true)
294/// Returns (None, false) if the harq field is not present
295fn parse_harq(line: &str) -> (Option<u8>, bool) {
296    let harq_start_idx = match line.find("harq=") {
297        Some(idx) => idx,
298        None => return (None, false),
299    };
300    let harq_value_start = harq_start_idx + 5; // Skip "harq="
301
302    let value_part: String = line[harq_value_start..]
303        .chars()
304        .take_while(|c| c.is_ascii_alphanumeric())
305        .collect();
306
307    if value_part == "si" {
308        return (None, true);
309    }
310
311    (value_part.parse::<u8>().ok(), false)
312}
313
314/// Extract a u8 value from a "key=value" field in a line
315fn extract_field_u8(line: &str, prefix: &str) -> Option<u8> {
316    let start = line.find(prefix)? + prefix.len();
317    let value: String = line[start..].chars().take_while(|c| c.is_ascii_digit()).collect();
318    value.parse::<u8>().ok()
319}
320
321/// Extract a string value from a "key=value" field in a line (until whitespace)
322fn extract_field_str(line: &str, prefix: &str) -> Option<String> {
323    let start = line.find(prefix)? + prefix.len();
324    let value: String = line[start..].chars().take_while(|c| !c.is_whitespace()).collect();
325    if value.is_empty() { None } else { Some(value) }
326}
327
328/// Parse PDCCH channel-specific data from multi-line trace
329///
330/// First line: `... PDCCH: ss_id=2 cce_index=6 al=2 dci=0_1 k2=4`
331/// Subsequent indented lines: `harq_process=0`, `ndi=1`, `rv_idx=0`, etc.
332fn parse_pdcch_data(lines: &[String]) -> PHYChannelData {
333    let first_line = &lines[0];
334    let dci = extract_field_str(first_line, "dci=").unwrap_or_default();
335    let is_dci_1_1 = dci == "1_1";
336
337    let mut harq_process = None;
338    let mut ndi = None;
339    let mut rv_idx = None;
340    let mut harq_feedback_timing = None;
341
342    for line in lines.iter().skip(1) {
343        let trimmed = line.trim();
344        if let Some(trimmed_prefix) = trimmed.strip_prefix("harq_process=") {
345            harq_process = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
346        } else if is_dci_1_1 {
347            // DCI 1_1 (DL grant): ndi1, rv_idx1, harq_feedback_timing
348            if let Some(trimmed_prefix) = trimmed.strip_prefix("ndi1=") {
349                ndi = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
350            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("rv_idx1=") {
351                rv_idx = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
352            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("harq_feedback_timing=") {
353                harq_feedback_timing = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
354            }
355        } else {
356            // DCI 0_1 (UL grant): ndi, rv_idx
357            if let Some(trimmed_prefix) = trimmed.strip_prefix("ndi=") {
358                ndi = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
359            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("rv_idx=") {
360                rv_idx = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
361            }
362        }
363    }
364
365    PHYChannelData::Pdcch {
366        dci,
367        harq_process,
368        ndi,
369        rv_idx,
370        harq_feedback_timing,
371    }
372}
373
374/// Parse PDSCH channel-specific data from the first line
375fn parse_pdsch_data(line: &str) -> PHYChannelData {
376    let retx = extract_field_u8(line, "retx=");
377    let rv_idx = extract_field_u8(line, "rv_idx=");
378    PHYChannelData::Pdsch { retx, rv_idx }
379}
380
381/// Parse PUSCH channel-specific data from the first line
382fn parse_pusch_data(line: &str) -> PHYChannelData {
383    let retx = extract_field_u8(line, "retx=");
384    let rv_idx = extract_field_u8(line, "rv_idx=");
385    let crc = extract_field_str(line, "crc=").map(|s| s != "KO");
386    PHYChannelData::Pusch { retx, rv_idx, crc }
387}
388
389/// Parse PUCCH channel-specific data from the first line
390///
391/// `ack` is only present on format=1. Sometimes `sr` appears instead of `ack`.
392/// ack != 0 means ACK (true), ack == 0 means NACK (false).
393fn parse_pucch_data(line: &str) -> PHYChannelData {
394    let format = extract_field_u8(line, "format=");
395    // ack field: may be multi-digit (e.g. "11", "111") — treat any non-"0" as true
396    let ack = extract_field_str(line, "ack=").map(|s| s != "0");
397    PHYChannelData::Pucch { format, ack }
398}
399
400/// Parse the prb field from the trace line
401///
402/// Formats:
403/// - "prb=50" → (50, 1)
404/// - "prb=23:2" → (23, 2)
405/// - "prb=2:4" → (2, 4)
406fn parse_prb(line: &str) -> Option<(u16, u16)> {
407    // Find "prb=" in the line
408    let prb_start_idx = line.find("prb=")?;
409    let prb_value_start = prb_start_idx + 4; // Skip "prb="
410
411    // Extract the value part
412    let value_part: String = line[prb_value_start..]
413        .chars()
414        .take_while(|c| c.is_ascii_digit() || *c == ':' || *c == ' ')
415        .collect();
416
417    let value_trimmed = value_part.trim();
418
419    if let Some(colon_pos) = value_trimmed.find(':') {
420        // Format: start:length
421        let start = value_trimmed[..colon_pos].parse::<u16>().ok()?;
422        let length = value_trimmed[colon_pos + 1..].parse::<u16>().ok()?;
423        Some((start, length))
424    } else {
425        // Format: single value = start:1
426        let start = value_trimmed.parse::<u16>().ok()?;
427        Some((start, 1))
428    }
429}
430
431/// Parse the symb field from the trace line
432///
433/// Formats:
434/// - "symb=0:13" → (0, 13)
435/// - "symb=1:13" → (1, 13)
436/// - "symb=0:14" → (0, 14)
437///
438/// Returns None if the symb field is not present
439fn parse_symb(line: &str) -> Option<(u8, u8)> {
440    // Find "symb=" in the line
441    let symb_start_idx = line.find("symb=")?;
442    let symb_value_start = symb_start_idx + 5; // Skip "symb="
443
444    // Extract the value part
445    let value_part: String = line[symb_value_start..]
446        .chars()
447        .take_while(|c| c.is_ascii_digit() || *c == ':' || *c == ' ')
448        .collect();
449
450    let value_trimmed = value_part.trim();
451
452    if let Some(colon_pos) = value_trimmed.find(':') {
453        // Format: start:length
454        let start = value_trimmed[..colon_pos].parse::<u8>().ok()?;
455        let length = value_trimmed[colon_pos + 1..].parse::<u8>().ok()?;
456        Some((start, length))
457    } else {
458        // Format: single value = start:1
459        let start = value_trimmed.parse::<u8>().ok()?;
460        Some((start, 1))
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_parse_5g_pdsch() {
470        let line = "10:32:34.715 [PHY] DL 0001 01 4601  431.16 PDSCH: harq=0 prb=50 symb=1:13 k1=12 CW0: tb_len=133 mod=8 rv_idx=0 cr=0.94 retx=0";
471        let info = parse_phy_line(line, Direction::DL).unwrap();
472
473        assert_eq!(info.frame, 431);
474        assert_eq!(info.slot, 16);
475        assert_eq!(info.prb_start, 50);
476        assert_eq!(info.prb_length, 1);
477        assert_eq!(info.symb_start, 1);
478        assert_eq!(info.symb_length, 13);
479        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
480        assert_eq!(info.harq, Some(0));
481        assert!(!info.harq_si);
482        assert!(matches!(
483            info.channel_data,
484            PHYChannelData::Pdsch {
485                retx: Some(0),
486                rv_idx: Some(0)
487            }
488        ));
489    }
490
491    #[test]
492    fn test_parse_4g_pdsch_no_symb() {
493        let line = "10:37:50.654 [PHY] DL 0001 01 003d   421.0 PDSCH: harq=0 k1=4 prb=23:2 tx=port0 CW0: tb_len=185 mod=6 rv_idx=0 retx=0";
494        let info = parse_phy_line(line, Direction::DL).unwrap();
495
496        assert_eq!(info.frame, 421);
497        assert_eq!(info.slot, 0);
498        assert_eq!(info.prb_start, 23);
499        assert_eq!(info.prb_length, 2);
500        assert_eq!(info.symb_start, 0);
501        assert_eq!(info.symb_length, 14); // Default for missing symb
502        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
503        assert_eq!(info.harq, Some(0));
504    }
505
506    #[test]
507    fn test_parse_pusch() {
508        let line = "10:37:38.884 [PHY] UL 0001 01 003d   267.5 PUSCH: harq=3 prb=2:4 symb=0:13 CW0: tb_len=11 mod=2 rv_idx=0 retx=0 crc=OK snr=26.2 epre=-35.6 ta=0.1";
509        let info = parse_phy_line(line, Direction::UL).unwrap();
510
511        assert_eq!(info.frame, 267);
512        assert_eq!(info.slot, 5);
513        assert_eq!(info.prb_start, 2);
514        assert_eq!(info.prb_length, 4);
515        assert_eq!(info.symb_start, 0);
516        assert_eq!(info.symb_length, 13);
517        assert!(matches!(info.channel_type, PHYChannelType::PUSCH));
518        assert_eq!(info.harq, Some(3));
519        assert!(matches!(
520            info.channel_data,
521            PHYChannelData::Pusch {
522                retx: Some(0),
523                rv_idx: Some(0),
524                crc: Some(true)
525            }
526        ));
527    }
528
529    #[test]
530    fn test_parse_pusch_crc_ko() {
531        let line = "10:32:34.569 [PHY] UL 0001 01 4601  416.19 PUSCH: harq=0 prb=2 symb=0:14 CW0: tb_len=145 mod=8 rv_idx=0 cr=0.94 retx=0 crc=KO snr=37.1 epre=-86.9 ta=-0.5";
532        let info = parse_phy_line(line, Direction::UL).unwrap();
533        assert!(matches!(info.channel_data, PHYChannelData::Pusch { crc: Some(false), .. }));
534    }
535
536    #[test]
537    fn test_parse_prb_single() {
538        let line = "10:37:50.654 [PHY] DL 0001 01 003d   421.0 PDSCH: prb=50 symb=1:13";
539        let info = parse_phy_line(line, Direction::DL).unwrap();
540        assert_eq!(info.prb_start, 50);
541        assert_eq!(info.prb_length, 1);
542    }
543
544    #[test]
545    fn test_parse_pdcch_dci_0_1() {
546        let lines: Vec<String> = vec![
547            "10:32:29.584 [PHY] DL 0001 01 4601  942.15 PDCCH: ss_id=2 cce_index=6 al=2 dci=0_1 k2=4".into(),
548            "\t\trb_alloc=0x30".into(),
549            "\t\ttime_domain_rsc=1".into(),
550            "\t\tmcs=27".into(),
551            "\t\tndi=1".into(),
552            "\t\trv_idx=0".into(),
553            "\t\tharq_process=0".into(),
554            "\t\tdai=3".into(),
555            "\t\ttpc_command=1".into(),
556            "\t\tantenna_ports=0".into(),
557            "\t\tsrs_request=0".into(),
558            "\t\tdmrs_seq_init=0".into(),
559            "\t\tul_sch_indicator=1".into(),
560        ];
561        let info = parse_phy_lines(&lines, Direction::DL).unwrap();
562        assert!(matches!(info.channel_type, PHYChannelType::PDCCH));
563        assert_eq!(info.frame, 942);
564        assert_eq!(info.slot, 15);
565        assert_eq!(info.prb_start, 0); // PDCCH has no prb on first line
566        match &info.channel_data {
567            PHYChannelData::Pdcch {
568                dci,
569                harq_process,
570                ndi,
571                rv_idx,
572                harq_feedback_timing,
573            } => {
574                assert_eq!(dci, "0_1");
575                assert_eq!(*harq_process, Some(0));
576                assert_eq!(*ndi, Some(1));
577                assert_eq!(*rv_idx, Some(0));
578                assert_eq!(*harq_feedback_timing, None);
579            }
580            _ => panic!("Expected Pdcch channel data"),
581        }
582    }
583
584    #[test]
585    fn test_parse_pdcch_dci_1_1() {
586        let lines: Vec<String> = vec![
587            "10:32:22.293 [PHY] DL 0001 01 4601  213.13 PDCCH: ss_id=2 cce_index=4 al=2 dci=1_1".into(),
588            "\t\trb_alloc=0x32".into(),
589            "\t\tmcs1=27".into(),
590            "\t\tndi1=0".into(),
591            "\t\trv_idx1=0".into(),
592            "\t\tharq_process=0".into(),
593            "\t\tharq_feedback_timing=2".into(),
594        ];
595        let info = parse_phy_lines(&lines, Direction::DL).unwrap();
596        match &info.channel_data {
597            PHYChannelData::Pdcch {
598                dci,
599                harq_process,
600                ndi,
601                rv_idx,
602                harq_feedback_timing,
603            } => {
604                assert_eq!(dci, "1_1");
605                assert_eq!(*harq_process, Some(0));
606                assert_eq!(*ndi, Some(0));
607                assert_eq!(*rv_idx, Some(0));
608                assert_eq!(*harq_feedback_timing, Some(2));
609            }
610            _ => panic!("Expected Pdcch channel data"),
611        }
612    }
613
614    #[test]
615    fn test_parse_pucch_ack() {
616        let line = "10:32:22.299 [PHY] UL 0001 01 4601  213.19 PUCCH: format=1 prb=50 prb2=0 symb=0:14 cs=1 occ=0 ack=1 snr=35.7 epre=-88.5";
617        let info = parse_phy_line(line, Direction::UL).unwrap();
618        assert!(matches!(info.channel_type, PHYChannelType::PUCCH));
619        match &info.channel_data {
620            PHYChannelData::Pucch { format, ack } => {
621                assert_eq!(*format, Some(1));
622                assert_eq!(*ack, Some(true));
623            }
624            _ => panic!("Expected Pucch channel data"),
625        }
626    }
627
628    #[test]
629    fn test_parse_pucch_sr() {
630        let line = "13:20:47.884 [PHY] UL 003d 01 4644   960.8 PUCCH: format=1 prb=50 prb2=0 symb=0:14 cs=9 occ=2 sr=1 snr=18.9 epre=-53.8";
631        let info = parse_phy_line(line, Direction::UL).unwrap();
632        match &info.channel_data {
633            PHYChannelData::Pucch { format, ack } => {
634                assert_eq!(*format, Some(1));
635                assert_eq!(*ack, None); // sr present, not ack
636            }
637            _ => panic!("Expected Pucch channel data"),
638        }
639    }
640
641    #[test]
642    fn test_parse_harq_si() {
643        let line = "13:20:47.877 [PHY] DL    - 01 ffff   960.0 PDSCH: harq=si prb=41:7 symb=2:12 CW0: tb_len=84 mod=2 rv_idx=0 cr=0.44";
644        let info = parse_phy_line(line, Direction::DL).unwrap();
645        assert_eq!(info.harq, None);
646        assert!(info.harq_si);
647        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
648    }
649
650    #[test]
651    fn test_parse_pucch_format2_no_ack() {
652        let line = "13:20:47.885 [PHY] UL 003d 01 4644   960.9 PUCCH: format=2 prb=1 prb2=49 symb=8:2 csi=0101 epre=-51.3";
653        let info = parse_phy_line(line, Direction::UL).unwrap();
654        match &info.channel_data {
655            PHYChannelData::Pucch { format, ack } => {
656                assert_eq!(*format, Some(2));
657                assert_eq!(*ack, None); // format=2 has no ack
658            }
659            _ => panic!("Expected Pucch channel data"),
660        }
661    }
662}