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, LayerParser, ParsedHeader};
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        /// ACK/NACK (true = ACK, false = NACK)
60        ack: Option<bool>,
61        /// Uplink power / timing / CSI measurements
62        measurements: UlMeasurements,
63    },
64    /// PUCCH uplink control channel data
65    Pucch {
66        /// PUCCH format (1, 2, etc.)
67        format: Option<u8>,
68        /// ACK/NACK (true = ACK, i.e. value != 0)
69        ack: Option<bool>,
70        /// Uplink power / timing / CSI measurements
71        measurements: UlMeasurements,
72    },
73    /// No channel-specific data
74    None,
75}
76
77/// Uplink measurements reported by the gNB on PUSCH / PUCCH traces
78#[derive(Debug, Clone, Default, PartialEq)]
79pub struct UlMeasurements {
80    /// Energy Per Resource Element (dB)
81    pub epre: Option<f32>,
82    /// Timing Advance (µs)
83    pub ta: Option<f32>,
84    /// Channel State Information, decoded from its binary representation
85    /// (e.g. `csi=0101` -> 5)
86    pub csi: Option<u32>,
87}
88
89/// PHY layer information extracted from trace lines
90#[derive(Debug, Clone)]
91pub struct PHYInfos {
92    /// Direction (UL or DL)
93    pub direction: Direction,
94    /// Channel type (PDSCH, PUSCH, etc.)
95    pub channel_type: PHYChannelType,
96    /// Frame number
97    pub frame: u16,
98    /// Slot number within frame
99    pub slot: u8,
100    /// PRB start position
101    pub prb_start: u16,
102    /// PRB length (number of PRBs)
103    pub prb_length: u16,
104    /// Symbol start position within slot
105    pub symb_start: u8,
106    /// Symbol length (number of symbols)
107    pub symb_length: u8,
108    /// HARQ process number (0-15 typically)
109    pub harq: Option<u8>,
110    /// True if harq=si (MIB/SIB carry, not a real HARQ process)
111    pub harq_si: bool,
112    /// Channel-specific parsed data
113    pub channel_data: PHYChannelData,
114}
115
116/// PHY layer parser
117pub struct PHYParser;
118
119impl PHYParser {
120    /// Parse PHY layer traces
121    fn parse_lines(lines: &[String]) -> Vec<String> {
122        lines.to_vec()
123    }
124}
125
126impl FileParser for PHYParser {
127    fn parse_additional_infos(lines: &[String]) -> Result<AdditionalInfos, ParsingError> {
128        if lines.is_empty() {
129            return Ok(AdditionalInfos::None);
130        }
131
132        let first_line = &lines[0];
133
134        // Determine direction from the line
135        let direction = if first_line.contains(" DL ") {
136            Direction::DL
137        } else if first_line.contains(" UL ") {
138            Direction::UL
139        } else {
140            Direction::DL // Default to DL
141        };
142
143        // Try to parse PHY info (pass all lines for multi-line PDCCH parsing)
144        match parse_phy_lines(lines, direction) {
145            Some(phy_infos) => {
146                // Store info for PDCCH/PDSCH/PUSCH/PUCCH/PRACH channels
147                if matches!(
148                    phy_infos.channel_type,
149                    PHYChannelType::PDCCH
150                        | PHYChannelType::PDSCH
151                        | PHYChannelType::PUSCH
152                        | PHYChannelType::PUCCH
153                        | PHYChannelType::PRACH
154                ) {
155                    Ok(AdditionalInfos::PHYInfos(phy_infos))
156                } else {
157                    Ok(AdditionalInfos::None)
158                }
159            }
160            None => Ok(AdditionalInfos::None),
161        }
162    }
163
164    fn parse(lines: &[String]) -> Result<Trace, ParsingError> {
165        let additional_infos = match Self::parse_additional_infos(lines) {
166            Ok(m) => m,
167            Err(e) => {
168                return Err(e);
169            }
170        };
171
172        let text = Self::parse_lines(lines);
173
174        let binary = extract_binary_from_lines(lines);
175
176        // Parse timestamp from first line
177        let timestamp = if let Some(first_line) = lines.first() {
178            super::parse_timestamp(first_line)?
179        } else {
180            0
181        };
182
183        let trace = Trace {
184            timestamp,
185            layer: Layer::PHY,
186            additional_infos,
187            text: Some(text),
188            binary,
189            relation: TraceRelation::default(),
190        };
191        Ok(trace)
192    }
193}
194
195/// Parse a PHY layer trace line and extract RB & HARQ information
196/// Accepts all lines of a PHY trace (first line + optional indented continuation
197/// lines for PDCCH). Extracts frame/slot, PRB, symbol, HARQ, and channel-specific data.
198///
199/// Example trace formats:
200/// - 5G PDSCH: `10:32:34.715 [PHY] DL 0001 01 4601  431.16 PDSCH: harq=0 prb=50 symb=1:13 k1=12...`
201/// - 4G PDSCH: `10:37:50.654 [PHY] DL 0001 01 003d   421.0 PDSCH: harq=0 k1=4 prb=23:2...` (no symb)
202///
203/// # Arguments
204/// * `lines` - All lines of the PHY trace (first line + indented fields)
205/// * `direction` - Direction (UL/DL) parsed from the trace header
206///
207/// # Returns
208/// * `Some(PHYInfos)` for handled channels (PDCCH, PDSCH, PUSCH, PUCCH, PRACH)
209/// * `None` for other PHY channels or if parsing fails
210pub fn parse_phy_lines(lines: &[String], direction: Direction) -> Option<PHYInfos> {
211    if lines.is_empty() {
212        return None;
213    }
214    let first_line = &lines[0];
215
216    // Detect channel type from first line
217    let channel_type = if first_line.contains("PDSCH:") {
218        PHYChannelType::PDSCH
219    } else if first_line.contains("PUSCH:") {
220        PHYChannelType::PUSCH
221    } else if first_line.contains("PUCCH:") {
222        PHYChannelType::PUCCH
223    } else if first_line.contains("PDCCH:") {
224        PHYChannelType::PDCCH
225    } else if first_line.contains("PRACH:") {
226        PHYChannelType::PRACH
227    } else {
228        PHYChannelType::Other
229    };
230
231    // Parse frame and slot from the "frame.slot" field (e.g., "421.0")
232    let (frame, slot) = parse_frame_slot(first_line)?;
233
234    // Parse prb field — PDCCH has no prb= on first line
235    let (prb_start, prb_length) = if matches!(channel_type, PHYChannelType::PDCCH) {
236        (0, 0)
237    } else {
238        parse_prb(first_line)?
239    };
240
241    // Parse symb field — if missing, assume full slot (0:14)
242    let (symb_start, symb_length) = parse_symb(first_line).unwrap_or((0, 14));
243
244    // Parse harq field: "harq=2" -> (Some(2), false), "harq=si" -> (None, true)
245    let (harq, harq_si) = parse_harq(first_line);
246
247    // Parse channel-specific data
248    let channel_data = match channel_type {
249        PHYChannelType::PDCCH => parse_pdcch_data(lines),
250        PHYChannelType::PDSCH => parse_pdsch_data(first_line),
251        PHYChannelType::PUSCH => parse_pusch_data(first_line),
252        PHYChannelType::PUCCH => parse_pucch_data(first_line),
253        _ => PHYChannelData::None,
254    };
255
256    Some(PHYInfos {
257        direction,
258        channel_type,
259        frame,
260        slot,
261        prb_start,
262        prb_length,
263        symb_start,
264        symb_length,
265        harq,
266        harq_si,
267        channel_data,
268    })
269}
270
271impl LayerParser for PHYParser {
272    /// Parse PHY payload from WebSocket data lines + ParsedHeader metadata.
273    /// header provides: direction, frame, slot, channel
274    /// data_lines contain key=value fields (e.g. ["ss_id=2 cce_index=12 al=2 dci=0_1 k2=4", "ndi=0", ...])
275    fn parse_layer(header: &ParsedHeader, data_lines: &[String]) -> Result<AdditionalInfos, ParsingError> {
276        let channel_name = header.channel.as_deref().unwrap_or("");
277        let channel_type = match channel_name {
278            "PDSCH" => PHYChannelType::PDSCH,
279            "PUSCH" => PHYChannelType::PUSCH,
280            "PUCCH" => PHYChannelType::PUCCH,
281            "PDCCH" => PHYChannelType::PDCCH,
282            "PRACH" => PHYChannelType::PRACH,
283            _ => PHYChannelType::Other,
284        };
285
286        if matches!(channel_type, PHYChannelType::Other) {
287            return Ok(AdditionalInfos::None);
288        }
289
290        let frame = header.frame.unwrap_or(0);
291        let slot = header.slot.unwrap_or(0);
292
293        // For WebSocket, the first data line may contain key=value pairs on one line
294        // Concatenate first line with remaining lines to build a unified view for helpers
295        let first_line = data_lines.first().map(|s| s.as_str()).unwrap_or("");
296
297        // Parse prb from the data lines (PDCCH has no prb)
298        let (prb_start, prb_length) = if matches!(channel_type, PHYChannelType::PDCCH) {
299            (0, 0)
300        } else {
301            parse_prb(first_line).unwrap_or((0, 0))
302        };
303
304        let (symb_start, symb_length) = parse_symb(first_line).unwrap_or((0, 14));
305        let (harq, harq_si) = parse_harq(first_line);
306
307        // Parse channel-specific data from all data lines
308        let channel_data = match channel_type {
309            PHYChannelType::PDCCH => parse_pdcch_data(data_lines),
310            PHYChannelType::PDSCH => parse_pdsch_data(first_line),
311            PHYChannelType::PUSCH => parse_pusch_data(first_line),
312            PHYChannelType::PUCCH => parse_pucch_data(first_line),
313            _ => PHYChannelData::None,
314        };
315
316        Ok(AdditionalInfos::PHYInfos(PHYInfos {
317            direction: header.direction.clone(),
318            channel_type,
319            frame,
320            slot,
321            prb_start,
322            prb_length,
323            symb_start,
324            symb_length,
325            harq,
326            harq_si,
327            channel_data,
328        }))
329    }
330}
331
332/// Convenience wrapper for single-line parsing (used in tests)
333pub fn parse_phy_line(line: &str, direction: Direction) -> Option<PHYInfos> {
334    parse_phy_lines(&[line.to_string()], direction)
335}
336
337/// Parse the frame.slot field from the trace line
338///
339/// The frame.slot appears after the UE ID fields, e.g.:
340/// `[PHY] DL 0001 01 003d   421.0 PDSCH:`
341///                                 ^^^^^
342fn parse_frame_slot(line: &str) -> Option<(u16, u8)> {
343    // Look for a pattern like "  421.0 " or "  431.16 "
344    // The frame.slot is typically after the layer/direction/UE fields
345
346    // Split by whitespace and look for a decimal number that looks like frame.slot
347    let parts: Vec<&str> = line.split_whitespace().collect();
348
349    for part in &parts {
350        // Check if part contains a dot and both parts are numeric
351        if let Some(dot_pos) = part.find('.') {
352            let frame_part = &part[..dot_pos];
353            let slot_part = &part[dot_pos + 1..];
354
355            // Make sure there's no trailing punctuation on slot_part
356            let slot_part: String = slot_part.chars().take_while(|c| c.is_ascii_digit()).collect();
357
358            if let (Ok(frame), Ok(slot)) = (frame_part.parse::<u16>(), slot_part.parse::<u8>()) {
359                // Sanity check: frame < 1024 (0-1023), slot < 20
360                if slot < 20 {
361                    return Some((frame, slot));
362                }
363            }
364        }
365    }
366
367    None
368}
369
370/// Parse the harq field from the trace line
371///
372/// Format: "harq=2" -> (Some(2), false), "harq=si" -> (None, true)
373/// Returns (None, false) if the harq field is not present
374fn parse_harq(line: &str) -> (Option<u8>, bool) {
375    let harq_start_idx = match line.find("harq=") {
376        Some(idx) => idx,
377        None => return (None, false),
378    };
379    let harq_value_start = harq_start_idx + 5; // Skip "harq="
380
381    let value_part: String = line[harq_value_start..]
382        .chars()
383        .take_while(|c| c.is_ascii_alphanumeric())
384        .collect();
385
386    if value_part == "si" {
387        return (None, true);
388    }
389
390    (value_part.parse::<u8>().ok(), false)
391}
392
393/// Extract a u8 value from a "key=value" field in a line
394fn extract_field_u8(line: &str, prefix: &str) -> Option<u8> {
395    let start = line.find(prefix)? + prefix.len();
396    let value: String = line[start..].chars().take_while(|c| c.is_ascii_digit()).collect();
397    value.parse::<u8>().ok()
398}
399
400/// Extract a string value from a "key=value" field in a line (until whitespace)
401fn extract_field_str(line: &str, prefix: &str) -> Option<String> {
402    let start = line.find(prefix)? + prefix.len();
403    let value: String = line[start..].chars().take_while(|c| !c.is_whitespace()).collect();
404    if value.is_empty() { None } else { Some(value) }
405}
406
407/// Parse PDCCH channel-specific data from multi-line trace
408///
409/// First line: `... PDCCH: ss_id=2 cce_index=6 al=2 dci=0_1 k2=4`
410/// Subsequent indented lines: `harq_process=0`, `ndi=1`, `rv_idx=0`, etc.
411fn parse_pdcch_data(lines: &[String]) -> PHYChannelData {
412    let first_line = &lines[0];
413    let dci = extract_field_str(first_line, "dci=").unwrap_or_default();
414    let is_dci_1_1 = dci == "1_1";
415
416    let mut harq_process = None;
417    let mut ndi = None;
418    let mut rv_idx = None;
419    let mut harq_feedback_timing = None;
420
421    for line in lines.iter().skip(1) {
422        let trimmed = line.trim();
423        if let Some(trimmed_prefix) = trimmed.strip_prefix("harq_process=") {
424            harq_process = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
425        } else if is_dci_1_1 {
426            // DCI 1_1 (DL grant): ndi1, rv_idx1, harq_feedback_timing
427            if let Some(trimmed_prefix) = trimmed.strip_prefix("ndi1=") {
428                ndi = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
429            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("rv_idx1=") {
430                rv_idx = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
431            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("harq_feedback_timing=") {
432                harq_feedback_timing = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
433            }
434        } else {
435            // DCI 0_1 (UL grant): ndi, rv_idx
436            if let Some(trimmed_prefix) = trimmed.strip_prefix("ndi=") {
437                ndi = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
438            } else if let Some(trimmed_prefix) = trimmed.strip_prefix("rv_idx=") {
439                rv_idx = trimmed_prefix.split_whitespace().next().and_then(|v| v.parse::<u8>().ok());
440            }
441        }
442    }
443
444    PHYChannelData::Pdcch {
445        dci,
446        harq_process,
447        ndi,
448        rv_idx,
449        harq_feedback_timing,
450    }
451}
452
453/// Parse PDSCH channel-specific data from the first line
454fn parse_pdsch_data(line: &str) -> PHYChannelData {
455    let retx = extract_field_u8(line, "retx=");
456    let rv_idx = extract_field_u8(line, "rv_idx=");
457    PHYChannelData::Pdsch { retx, rv_idx }
458}
459
460/// Parse PUSCH channel-specific data from the first line
461fn parse_pusch_data(line: &str) -> PHYChannelData {
462    let retx = extract_field_u8(line, "retx=");
463    let rv_idx = extract_field_u8(line, "rv_idx=");
464    let crc = extract_field_str(line, "crc=").map(|s| s != "KO");
465    let ack = extract_field_str(line, "ack=").map(|s| s != "0");
466    PHYChannelData::Pusch {
467        retx,
468        rv_idx,
469        crc,
470        ack,
471        measurements: parse_ul_measurements(line),
472    }
473}
474
475/// Parse `epre=`, `ta=` and `csi=` fields shared by PUSCH / PUCCH traces
476fn parse_ul_measurements(line: &str) -> UlMeasurements {
477    UlMeasurements {
478        epre: extract_field_str(line, "epre=").and_then(|s| s.parse::<f32>().ok()),
479        ta: extract_field_str(line, "ta=").and_then(|s| s.parse::<f32>().ok()),
480        csi: extract_field_str(line, "csi=").and_then(|s| u32::from_str_radix(&s, 2).ok()),
481    }
482}
483
484/// Parse PUCCH channel-specific data from the first line
485///
486/// `ack` is only present on format=1. Sometimes `sr` appears instead of `ack`.
487/// ack != 0 means ACK (true), ack == 0 means NACK (false).
488fn parse_pucch_data(line: &str) -> PHYChannelData {
489    let format = extract_field_u8(line, "format=");
490    // ack field: may be multi-digit (e.g. "11", "111") — treat any non-"0" as true
491    let ack = extract_field_str(line, "ack=").map(|s| s != "0");
492    PHYChannelData::Pucch {
493        format,
494        ack,
495        measurements: parse_ul_measurements(line),
496    }
497}
498
499/// Parse the prb field from the trace line
500///
501/// Formats:
502/// - "prb=50" → (50, 1)
503/// - "prb=23:2" → (23, 2)
504/// - "prb=2:4" → (2, 4)
505fn parse_prb(line: &str) -> Option<(u16, u16)> {
506    // Find "prb=" in the line
507    let prb_start_idx = line.find("prb=")?;
508    let prb_value_start = prb_start_idx + 4; // Skip "prb="
509
510    // Extract the value part
511    let value_part: String = line[prb_value_start..]
512        .chars()
513        .take_while(|c| c.is_ascii_digit() || *c == ':' || *c == ' ')
514        .collect();
515
516    let value_trimmed = value_part.trim();
517
518    if let Some(colon_pos) = value_trimmed.find(':') {
519        // Format: start:length
520        let start = value_trimmed[..colon_pos].parse::<u16>().ok()?;
521        let length = value_trimmed[colon_pos + 1..].parse::<u16>().ok()?;
522        Some((start, length))
523    } else {
524        // Format: single value = start:1
525        let start = value_trimmed.parse::<u16>().ok()?;
526        Some((start, 1))
527    }
528}
529
530/// Parse the symb field from the trace line
531///
532/// Formats:
533/// - "symb=0:13" → (0, 13)
534/// - "symb=1:13" → (1, 13)
535/// - "symb=0:14" → (0, 14)
536///
537/// Returns None if the symb field is not present
538fn parse_symb(line: &str) -> Option<(u8, u8)> {
539    // Find "symb=" in the line
540    let symb_start_idx = line.find("symb=")?;
541    let symb_value_start = symb_start_idx + 5; // Skip "symb="
542
543    // Extract the value part
544    let value_part: String = line[symb_value_start..]
545        .chars()
546        .take_while(|c| c.is_ascii_digit() || *c == ':' || *c == ' ')
547        .collect();
548
549    let value_trimmed = value_part.trim();
550
551    if let Some(colon_pos) = value_trimmed.find(':') {
552        // Format: start:length
553        let start = value_trimmed[..colon_pos].parse::<u8>().ok()?;
554        let length = value_trimmed[colon_pos + 1..].parse::<u8>().ok()?;
555        Some((start, length))
556    } else {
557        // Format: single value = start:1
558        let start = value_trimmed.parse::<u8>().ok()?;
559        Some((start, 1))
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn test_parse_5g_pdsch() {
569        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";
570        let info = parse_phy_line(line, Direction::DL).unwrap();
571
572        assert_eq!(info.frame, 431);
573        assert_eq!(info.slot, 16);
574        assert_eq!(info.prb_start, 50);
575        assert_eq!(info.prb_length, 1);
576        assert_eq!(info.symb_start, 1);
577        assert_eq!(info.symb_length, 13);
578        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
579        assert_eq!(info.harq, Some(0));
580        assert!(!info.harq_si);
581        assert!(matches!(
582            info.channel_data,
583            PHYChannelData::Pdsch {
584                retx: Some(0),
585                rv_idx: Some(0)
586            }
587        ));
588    }
589
590    #[test]
591    fn test_parse_4g_pdsch_no_symb() {
592        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";
593        let info = parse_phy_line(line, Direction::DL).unwrap();
594
595        assert_eq!(info.frame, 421);
596        assert_eq!(info.slot, 0);
597        assert_eq!(info.prb_start, 23);
598        assert_eq!(info.prb_length, 2);
599        assert_eq!(info.symb_start, 0);
600        assert_eq!(info.symb_length, 14); // Default for missing symb
601        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
602        assert_eq!(info.harq, Some(0));
603    }
604
605    #[test]
606    fn test_parse_pusch() {
607        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 ack=1";
608        let info = parse_phy_line(line, Direction::UL).unwrap();
609        // println!("{:?}", info);
610        assert_eq!(info.frame, 267);
611        assert_eq!(info.slot, 5);
612        assert_eq!(info.prb_start, 2);
613        assert_eq!(info.prb_length, 4);
614        assert_eq!(info.symb_start, 0);
615        assert_eq!(info.symb_length, 13);
616        assert!(matches!(info.channel_type, PHYChannelType::PUSCH));
617        assert_eq!(info.harq, Some(3));
618        match &info.channel_data {
619            PHYChannelData::Pusch {
620                retx,
621                rv_idx,
622                crc,
623                ack,
624                measurements,
625            } => {
626                assert_eq!(*retx, Some(0));
627                assert_eq!(*rv_idx, Some(0));
628                assert_eq!(*crc, Some(true));
629                assert_eq!(*ack, Some(true));
630                assert_eq!(measurements.epre, Some(-35.6));
631                assert_eq!(measurements.ta, Some(0.1));
632                assert_eq!(measurements.csi, None);
633            }
634            _ => panic!("Expected Pusch channel data"),
635        }
636    }
637
638    #[test]
639    fn test_parse_pusch_measurements_with_csi() {
640        let line = "13:20:46.485 [PHY] UL 003d 01 4644   820.9 PUSCH: harq=0 prb=2 symb=0:14 CW0: tb_len=141 mod=8 rv_idx=0 cr=0.92 retx=0 crc=KO snr=24.4 epre=-45.4 ta=0.1 csi=0101";
641        let info = parse_phy_line(line, Direction::UL).unwrap();
642        match &info.channel_data {
643            PHYChannelData::Pusch { measurements, .. } => {
644                assert_eq!(measurements.epre, Some(-45.4));
645                assert_eq!(measurements.ta, Some(0.1));
646                assert_eq!(measurements.csi, Some(5));
647            }
648            _ => panic!("Expected Pusch channel data"),
649        }
650    }
651
652    #[test]
653    fn test_parse_pusch_crc_ko() {
654        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";
655        let info = parse_phy_line(line, Direction::UL).unwrap();
656        assert!(matches!(info.channel_data, PHYChannelData::Pusch { crc: Some(false), .. }));
657    }
658
659    #[test]
660    fn test_parse_prb_single() {
661        let line = "10:37:50.654 [PHY] DL 0001 01 003d   421.0 PDSCH: prb=50 symb=1:13";
662        let info = parse_phy_line(line, Direction::DL).unwrap();
663        assert_eq!(info.prb_start, 50);
664        assert_eq!(info.prb_length, 1);
665    }
666
667    #[test]
668    fn test_parse_pdcch_dci_0_1() {
669        let lines: Vec<String> = vec![
670            "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(),
671            "\t\trb_alloc=0x30".into(),
672            "\t\ttime_domain_rsc=1".into(),
673            "\t\tmcs=27".into(),
674            "\t\tndi=1".into(),
675            "\t\trv_idx=0".into(),
676            "\t\tharq_process=0".into(),
677            "\t\tdai=3".into(),
678            "\t\ttpc_command=1".into(),
679            "\t\tantenna_ports=0".into(),
680            "\t\tsrs_request=0".into(),
681            "\t\tdmrs_seq_init=0".into(),
682            "\t\tul_sch_indicator=1".into(),
683        ];
684        let info = parse_phy_lines(&lines, Direction::DL).unwrap();
685        assert!(matches!(info.channel_type, PHYChannelType::PDCCH));
686        assert_eq!(info.frame, 942);
687        assert_eq!(info.slot, 15);
688        assert_eq!(info.prb_start, 0); // PDCCH has no prb on first line
689        match &info.channel_data {
690            PHYChannelData::Pdcch {
691                dci,
692                harq_process,
693                ndi,
694                rv_idx,
695                harq_feedback_timing,
696            } => {
697                assert_eq!(dci, "0_1");
698                assert_eq!(*harq_process, Some(0));
699                assert_eq!(*ndi, Some(1));
700                assert_eq!(*rv_idx, Some(0));
701                assert_eq!(*harq_feedback_timing, None);
702            }
703            _ => panic!("Expected Pdcch channel data"),
704        }
705    }
706
707    #[test]
708    fn test_parse_pdcch_dci_1_1() {
709        let lines: Vec<String> = vec![
710            "10:32:22.293 [PHY] DL 0001 01 4601  213.13 PDCCH: ss_id=2 cce_index=4 al=2 dci=1_1".into(),
711            "\t\trb_alloc=0x32".into(),
712            "\t\tmcs1=27".into(),
713            "\t\tndi1=0".into(),
714            "\t\trv_idx1=0".into(),
715            "\t\tharq_process=0".into(),
716            "\t\tharq_feedback_timing=2".into(),
717        ];
718        let info = parse_phy_lines(&lines, Direction::DL).unwrap();
719        match &info.channel_data {
720            PHYChannelData::Pdcch {
721                dci,
722                harq_process,
723                ndi,
724                rv_idx,
725                harq_feedback_timing,
726            } => {
727                assert_eq!(dci, "1_1");
728                assert_eq!(*harq_process, Some(0));
729                assert_eq!(*ndi, Some(0));
730                assert_eq!(*rv_idx, Some(0));
731                assert_eq!(*harq_feedback_timing, Some(2));
732            }
733            _ => panic!("Expected Pdcch channel data"),
734        }
735    }
736
737    #[test]
738    fn test_parse_pucch_ack() {
739        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";
740        let info = parse_phy_line(line, Direction::UL).unwrap();
741        assert!(matches!(info.channel_type, PHYChannelType::PUCCH));
742        match &info.channel_data {
743            PHYChannelData::Pucch {
744                format,
745                ack,
746                measurements,
747            } => {
748                assert_eq!(*format, Some(1));
749                assert_eq!(*ack, Some(true));
750                assert_eq!(measurements.epre, Some(-88.5));
751                assert_eq!(measurements.ta, None);
752            }
753            _ => panic!("Expected Pucch channel data"),
754        }
755    }
756
757    #[test]
758    fn test_parse_pucch_sr() {
759        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";
760        let info = parse_phy_line(line, Direction::UL).unwrap();
761        match &info.channel_data {
762            PHYChannelData::Pucch { format, ack, .. } => {
763                assert_eq!(*format, Some(1));
764                assert_eq!(*ack, None); // sr present, not ack
765            }
766            _ => panic!("Expected Pucch channel data"),
767        }
768    }
769
770    #[test]
771    fn test_parse_harq_si() {
772        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";
773        let info = parse_phy_line(line, Direction::DL).unwrap();
774        assert_eq!(info.harq, None);
775        assert!(info.harq_si);
776        assert!(matches!(info.channel_type, PHYChannelType::PDSCH));
777    }
778
779    #[test]
780    fn test_parse_pucch_format2_no_ack() {
781        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";
782        let info = parse_phy_line(line, Direction::UL).unwrap();
783        match &info.channel_data {
784            PHYChannelData::Pucch {
785                format,
786                ack,
787                measurements,
788            } => {
789                assert_eq!(*format, Some(2));
790                assert_eq!(*ack, None); // format=2 has no ack
791                assert_eq!(measurements.csi, Some(5));
792                assert_eq!(measurements.epre, Some(-51.3));
793            }
794            _ => panic!("Expected Pucch channel data"),
795        }
796    }
797}