Skip to main content

flow_fcs/
metadata.rs

1use super::{
2    byteorder::ByteOrder,
3    datatype::FcsDataType,
4    header::Header,
5    keyword::{
6        ByteKeyword, FloatKeyword, IntegerKeyword, IntegerableKeyword, Keyword,
7        KeywordCreationResult, MixedKeyword, StringKeyword, match_and_parse_keyword,
8    },
9};
10use anyhow::{Result, anyhow};
11use memmap3::Mmap;
12use regex::bytes::Regex;
13use rustc_hash::FxHashMap;
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16use uuid::Uuid;
17pub type KeywordMap = FxHashMap<String, Keyword>;
18
19/// Contains keyword-value pairs and delimiter from the TEXT segment of an FCS file
20///
21/// The TEXT segment contains all metadata about the FCS file, including:
22/// - File information (GUID, filename, cytometer type)
23/// - Data structure information (number of events, parameters, data type, byte order)
24/// - Parameter metadata (names, labels, ranges, transforms)
25/// - Optional information (compensation matrices, timestamps, etc.)
26///
27/// Keywords are stored in a hashmap for fast lookup, with type-safe accessors
28/// for different keyword types (integer, float, string, byte, mixed).
29#[derive(Default, Debug, Clone, Serialize, Deserialize)]
30pub struct Metadata {
31    pub keywords: KeywordMap,
32    pub delimiter: char,
33}
34
35impl Metadata {
36    #[must_use]
37    pub fn new() -> Self {
38        Self {
39            keywords: FxHashMap::default(),
40            delimiter: ' ',
41        }
42    }
43    /// Prints all keywords sorted alphabetically by key name
44    ///
45    /// This is a debugging utility that displays all keyword-value pairs
46    /// in the metadata, sorted for easy reading.
47    pub fn print_sorted_by_keyword(&self) {
48        // Step 1: Get a Vector from existing text HashMap.
49        let mut sorted: Vec<_> = self.keywords.iter().collect();
50
51        // Step 2: sort Vector by key from HashMap.
52        // ... This sorts by HashMap keys.
53        //     Each tuple is sorted by its first item [.0] (the key).
54        sorted.sort_by_key(|a| a.0);
55
56        // Step 3: loop over sorted vector.
57        for (key, value) in &sorted {
58            println!("{key}: {value}");
59        }
60    }
61    /// Reads the text segment of the fcs file and returns an `Metadata` struct
62    ///
63    /// Uses memchr for fast delimiter finding (5-10x faster than byte-by-byte iteration)
64    #[must_use]
65    pub fn from_mmap(mmap: &Mmap, header: &Header) -> Self {
66        // Read the first byte of the text segment to determine the delimiter:
67        let delimiter = mmap[*header.text_offset.start()];
68
69        // Read the text content
70        // header.text_offset is RangeInclusive, so we use it directly but SKIP the first byte, which is the delimiter (used above)
71        let text_slice = &mmap[(*header.text_offset.start() + 1)..=*header.text_offset.end()];
72
73        // Extract keyword value pairs using memchr for fast delimiter finding
74        let mut keywords: KeywordMap = FxHashMap::default();
75
76        // Find all delimiter positions using SIMD-accelerated search
77        // This is 5-10x faster than manual iteration
78        let delimiter_positions: Vec<usize> = memchr::memchr_iter(delimiter, text_slice).collect();
79
80        // Parse keyword-value pairs
81        // FCS format: |KEY1|VALUE1|KEY2|VALUE2|...
82        // delimiter_positions gives us the split points
83        let mut prev_pos = 0;
84        let mut is_keyword = true;
85        let mut current_key = String::new();
86
87        for &pos in &delimiter_positions {
88            // Extract the slice between delimiters
89            let segment = &text_slice[prev_pos..pos];
90
91            // SAFETY: FCS spec requires TEXT segment to be ASCII/UTF-8
92            let text = std::str::from_utf8(segment).unwrap_or_default();
93
94            if is_keyword {
95                // This is a keyword
96                current_key = text.to_string();
97                is_keyword = false;
98            } else {
99                // This is a value - parse and store the keyword-value pair
100                if !current_key.is_empty() {
101                    // Normalize key: ensure it has $ prefix (FCS spec requires it)
102                    // Store with $ prefix for consistent lookups
103                    let normalized_key: String = if current_key.starts_with('$') {
104                        current_key.clone()
105                    } else {
106                        format!("${}", current_key)
107                    };
108
109                    match match_and_parse_keyword(&current_key, text) {
110                        KeywordCreationResult::Int(int_keyword) => {
111                            keywords.insert(normalized_key.clone(), Keyword::Int(int_keyword));
112                        }
113                        KeywordCreationResult::Float(float_keyword) => {
114                            keywords.insert(normalized_key.clone(), Keyword::Float(float_keyword));
115                        }
116                        KeywordCreationResult::String(string_keyword) => {
117                            keywords
118                                .insert(normalized_key.clone(), Keyword::String(string_keyword));
119                        }
120                        KeywordCreationResult::Byte(byte_keyword) => {
121                            keywords.insert(normalized_key.clone(), Keyword::Byte(byte_keyword));
122                        }
123                        KeywordCreationResult::Mixed(mixed_keyword) => {
124                            keywords.insert(normalized_key.clone(), Keyword::Mixed(mixed_keyword));
125                        }
126                        KeywordCreationResult::UnableToParse => {
127                            eprintln!(
128                                "Unable to parse keyword: {} with value: {}",
129                                current_key, text
130                            );
131                        }
132                    }
133                }
134                current_key.clear();
135                is_keyword = true;
136            }
137
138            prev_pos = pos + 1;
139        }
140
141        // Handle the segment after the last delimiter (if any)
142        if prev_pos < text_slice.len() {
143            let segment = &text_slice[prev_pos..];
144            let text = std::str::from_utf8(segment).unwrap_or_default();
145
146            if !text.is_empty() {
147                if is_keyword {
148                    // This is a keyword without a value - shouldn't happen in valid FCS files
149                    eprintln!(
150                        "Warning: Keyword '{}' at end of text segment has no value \n {:?}",
151                        text, header
152                    );
153                } else {
154                    // This is a value - store the keyword-value pair
155                    if !current_key.is_empty() {
156                        let normalized_key: String = if current_key.starts_with('$') {
157                            current_key.clone()
158                        } else {
159                            format!("${}", current_key)
160                        };
161
162                        match match_and_parse_keyword(&current_key, text) {
163                            KeywordCreationResult::Int(int_keyword) => {
164                                keywords.insert(normalized_key.clone(), Keyword::Int(int_keyword));
165                            }
166                            KeywordCreationResult::Float(float_keyword) => {
167                                keywords
168                                    .insert(normalized_key.clone(), Keyword::Float(float_keyword));
169                            }
170                            KeywordCreationResult::String(string_keyword) => {
171                                keywords.insert(
172                                    normalized_key.clone(),
173                                    Keyword::String(string_keyword),
174                                );
175                            }
176                            KeywordCreationResult::Byte(byte_keyword) => {
177                                keywords
178                                    .insert(normalized_key.clone(), Keyword::Byte(byte_keyword));
179                            }
180                            KeywordCreationResult::Mixed(mixed_keyword) => {
181                                keywords
182                                    .insert(normalized_key.clone(), Keyword::Mixed(mixed_keyword));
183                            }
184                            KeywordCreationResult::UnableToParse => {
185                                eprintln!(
186                                    "Unable to parse keyword: {} with value: {}",
187                                    current_key, text
188                                );
189                            }
190                        }
191                    }
192                }
193            }
194        }
195
196        Self {
197            keywords,
198            delimiter: delimiter as char,
199        }
200    }
201
202    /// Check that required keys are present in the TEXT segment of the metadata
203    /// # Errors
204    /// Will return `Err` if:
205    /// - any of the required keywords are missing from the keywords hashmap
206    /// - the number of parameters can't be obtained from the $PAR keyword in the TEXT section
207    /// - any keyword has a Pn[X] value where n is greater than the number of parameters indicated by the $PAR keyword
208    pub fn validate_text_segment_keywords(&self, header: &Header) -> Result<()> {
209        println!("Validating FCS file...{}", header.version);
210        let required_keywords = header.version.get_required_keywords();
211        for keyword in required_keywords {
212            if !self.keywords.contains_key(*keyword) {
213                return Err(anyhow!(
214                    "Invalid FCS {:?} file: Missing keyword: {}",
215                    header.version,
216                    keyword
217                ));
218            }
219        }
220
221        Ok(())
222    }
223
224    /// Validates if a GUID is present in the file's metadata, and if not, generates a new one.
225    pub fn validate_guid(&mut self) {
226        if self.get_string_keyword("GUID").is_err() {
227            self.insert_string_keyword("GUID".to_string(), Uuid::new_v4().to_string());
228        }
229    }
230
231    /// Confirm that no stored keyword has a value greater than the $PAR keyword indicates
232    #[allow(unused)]
233    fn validate_number_of_parameters(&self) -> Result<()> {
234        let n_params = self.get_number_of_parameters()?;
235        let n_params_string = n_params.to_string();
236        let n_digits = n_params_string.chars().count().to_string();
237        let regex_string = r"[PR]\d{1,".to_string() + &n_digits + "}[BENRDFGLOPSTVIW]";
238        let param_keywords = Regex::new(&regex_string)?;
239
240        for keyword in self.keywords.keys() {
241            if !param_keywords.is_match(keyword.as_bytes()) {
242                continue; // Skip to the next iteration if the keyword doesn't match
243            }
244
245            // If the keyword starts with a $P, then the value of the next non-terminal characters should be less than or equal to the number of parameters
246            if keyword.starts_with("$P") {
247                let param_number = keyword
248                    .chars()
249                    .nth(1)
250                    .ok_or_else(|| anyhow!("Keyword '{}' should have a second character after '$P'", keyword))?
251                    .to_digit(10)
252                    .ok_or_else(|| anyhow!("Keyword '{}' should have a digit as the second character to count parameters", keyword))? as usize;
253                if param_number > *n_params {
254                    return Err(anyhow!(
255                        "Invalid FCS file: {} keyword value exceeds number of parameters",
256                        keyword
257                    ));
258                }
259            }
260        }
261
262        Ok(())
263    }
264    /// Generic function to get the unwrapped unsigned integer value associated with a numeric keyword (e.g. $PAR, $TOT, etc.)
265    fn get_keyword_value_as_usize(&self, keyword: &str) -> Result<&usize> {
266        Ok(self.get_integer_keyword(keyword)?.get_usize())
267    }
268
269    /// Return the number of parameters in the file from the $PAR keyword in the metadata TEXT section
270    /// # Errors
271    /// Will return `Err` if the $PAR keyword is not present in the metadata keywords hashmap
272    pub fn get_number_of_parameters(&self) -> Result<&usize> {
273        self.get_keyword_value_as_usize("$PAR")
274    }
275
276    /// Return the number of events in the file from the $TOT keyword in the metadata TEXT section
277    /// # Errors
278    /// Will return `Err` if the $TOT keyword is not present in the metadata keywords hashmap
279    pub fn get_number_of_events(&self) -> Result<&usize> {
280        self.get_keyword_value_as_usize("$TOT")
281    }
282
283    /// Return the data type from the $DATATYPE keyword in the metadata TEXT section, unwraps and returns it if it exists.
284    /// # Errors
285    /// Will return `Err` if the $DATATYPE keyword is not present in the metadata keywords hashmap
286    pub fn get_data_type(&self) -> Result<&FcsDataType> {
287        let keyword = self.get_byte_keyword("$DATATYPE")?;
288        if let ByteKeyword::DATATYPE(data_type) = keyword {
289            Ok(data_type)
290        } else {
291            Err(anyhow!("No $DATATYPE value stored."))
292        }
293    }
294
295    /// Get the data type for a specific channel/parameter (FCS 3.2+)
296    ///
297    /// First checks for `$PnDATATYPE` keyword to see if this parameter has a specific data type override.
298    /// If not found, falls back to the default `$DATATYPE` keyword.
299    ///
300    /// # Arguments
301    /// * `parameter_number` - 1-based parameter index
302    ///
303    /// # Errors
304    /// Will return `Err` if neither `$PnDATATYPE` nor `$DATATYPE` is present
305    pub fn get_data_type_for_channel(&self, parameter_number: usize) -> Result<FcsDataType> {
306        // First try to get parameter-specific data type (FCS 3.2+)
307        if let Ok(pn_datatype_keyword) =
308            self.get_parameter_byte_metadata(parameter_number, "DATATYPE")
309        {
310            if let ByteKeyword::PnDATATYPE(data_type) = pn_datatype_keyword {
311                Ok(*data_type)
312            } else {
313                // Shouldn't happen, but fall back to default
314                Ok(self.get_data_type()?.clone())
315            }
316        } else {
317            // Fall back to default $DATATYPE
318            Ok(self.get_data_type()?.clone())
319        }
320    }
321
322    /// Calculate the total bytes per event by summing bytes per parameter
323    ///
324    /// Uses `$PnB` (bits per parameter) divided by 8 to get bytes per parameter,
325    /// then sums across all parameters. This is more accurate than using `$DATATYPE`
326    /// which only provides a default value.
327    ///
328    /// # Errors
329    /// Will return `Err` if the number of parameters cannot be determined or
330    /// if any required `$PnB` keyword is missing
331    pub fn calculate_bytes_per_event(&self) -> Result<usize> {
332        let number_of_parameters = self.get_number_of_parameters()?;
333        let mut total_bytes = 0;
334
335        for param_num in 1..=*number_of_parameters {
336            // Get $PnB (bits per parameter)
337            let bits = self.get_parameter_numeric_metadata(param_num, "B")?;
338            if let IntegerKeyword::PnB(bits_value) = bits {
339                // Convert bits to bytes (round up if not divisible by 8)
340                let bytes = (bits_value + 7) / 8;
341                total_bytes += bytes;
342            } else {
343                return Err(anyhow!(
344                    "$P{}B keyword found but is not the expected PnB variant",
345                    param_num
346                ));
347            }
348        }
349
350        Ok(total_bytes)
351    }
352
353    /// Get bytes per parameter for a specific channel
354    ///
355    /// Uses `$PnB` (bits per parameter) divided by 8 to get bytes per parameter.
356    ///
357    /// # Arguments
358    /// * `parameter_number` - 1-based parameter index
359    ///
360    /// # Errors
361    /// Will return `Err` if the `$PnB` keyword is missing for this parameter
362    pub fn get_bytes_per_parameter(&self, parameter_number: usize) -> Result<usize> {
363        let bits = self.get_parameter_numeric_metadata(parameter_number, "B")?;
364        if let IntegerKeyword::PnB(bits_value) = bits {
365            // Convert bits to bytes (round up if not divisible by 8)
366            Ok((bits_value + 7) / 8)
367        } else {
368            Err(anyhow!(
369                "$P{}B keyword found but is not the expected PnB variant",
370                parameter_number
371            ))
372        }
373    }
374
375    /// Return the byte order from the $BYTEORD keyword in the metadata TEXT section, unwraps and returns it if it exists.
376    /// # Errors
377    /// Will return `Err` if the $BYTEORD keyword is not present in the keywords hashmap
378    pub fn get_byte_order(&self) -> Result<&ByteOrder> {
379        let keyword = self.get_byte_keyword("$BYTEORD")?;
380        if let ByteKeyword::BYTEORD(byte_order) = keyword {
381            Ok(byte_order)
382        } else {
383            Err(anyhow!("No $BYTEORD value stored."))
384        }
385    }
386    /// Returns a keyword that holds numeric data from the keywords hashmap, if it exists
387    /// # Errors
388    /// Will return `Err` if the keyword is not present in the keywords hashmap
389    pub fn get_integer_keyword(&self, keyword: &str) -> Result<&IntegerKeyword> {
390        if let Some(keyword) = self.keywords.get(keyword) {
391            match keyword {
392                Keyword::Int(integer) => Ok(integer),
393                _ => Err(anyhow!("Keyword is not integer variant")),
394            }
395        } else {
396            Err(anyhow!("No {keyword} keyword stored."))
397        }
398    }
399
400    /// Returns a keyword that holds numeric data from the keywords hashmap, if it exists
401    /// # Errors
402    /// Will return `Err` if the keyword is not present in the keywords hashmap
403    pub fn get_float_keyword(&self, keyword: &str) -> Result<&FloatKeyword> {
404        if let Some(keyword) = self.keywords.get(keyword) {
405            match keyword {
406                Keyword::Float(float) => Ok(float),
407                _ => Err(anyhow!("Keyword is not float variant")),
408            }
409        } else {
410            Err(anyhow!("No {keyword} keyword stored."))
411        }
412    }
413
414    /// Returns a keyword that holds string data from the keywords hashmap, if it exists
415    /// # Errors
416    /// Will return `Err` if the keyword is not present in the keywords hashmap
417    pub fn get_string_keyword(&self, keyword: &str) -> Result<&StringKeyword> {
418        if let Some(keyword) = self.keywords.get(keyword) {
419            match keyword {
420                Keyword::String(string) => Ok(string),
421                _ => Err(anyhow!("Keyword is not a string variant")),
422            }
423        } else {
424            Err(anyhow!("No {keyword} keyword stored."))
425        }
426    }
427
428    /// Returns a keyword that holds byte-orientation data from the keywords hashmap, if it exists
429    /// # Errors
430    /// Will return `Err` if the keyword is not present in the keywords hashmap
431    pub fn get_byte_keyword(&self, keyword: &str) -> Result<&ByteKeyword> {
432        if let Some(keyword) = self.keywords.get(keyword) {
433            match keyword {
434                Keyword::Byte(byte) => Ok(byte),
435                _ => Err(anyhow!("Keyword is not a byte variant")),
436            }
437        } else {
438            Err(anyhow!("No {keyword} keyword stored."))
439        }
440    }
441
442    /// Returns a keyword that holds mixed data from the keywords hashmap, if it exists
443    /// # Errors
444    /// Will return `Err` if the keyword is not present in the keywords hashmap
445    pub fn get_mixed_keyword(&self, keyword: &str) -> Result<&MixedKeyword> {
446        if let Some(keyword) = self.keywords.get(keyword) {
447            match keyword {
448                Keyword::Mixed(mixed) => Ok(mixed),
449                _ => Err(anyhow!("Keyword is not a mixed variant")),
450            }
451        } else {
452            Err(anyhow!("No {keyword} keyword stored."))
453        }
454    }
455
456    /// General function to get a given parameter's string keyword from the file's metadata (e.g. `$PnN` or `$PnS`)
457    /// # Errors
458    /// Will return `Err` if the keyword is not present in the keywords hashmap
459    pub fn get_parameter_string_metadata(
460        &self,
461        parameter_number: usize,
462        suffix: &str,
463    ) -> Result<&StringKeyword> {
464        // Interpolate the parameter number into the keyword:
465        let keyword = format!("$P{parameter_number}{suffix}");
466        self.get_string_keyword(&keyword)
467    }
468
469    /// Generic function to get a given parameter's integer keyword from the file's metadata (e.g. `$PnN`, `$PnS`)
470    /// # Errors
471    /// Will return `Err` if the keyword is not present in the keywords hashmap
472    pub fn get_parameter_numeric_metadata(
473        &self,
474        parameter_number: usize,
475        suffix: &str,
476    ) -> Result<&IntegerKeyword> {
477        // Interpolate the parameter number into the keyword:
478        let keyword = format!("$P{parameter_number}{suffix}");
479        self.get_integer_keyword(&keyword)
480    }
481
482    /// Generic function to get a given parameter's byte keyword from the file's metadata (e.g. `$PnDATATYPE`)
483    /// # Errors
484    /// Will return `Err` if the keyword is not present in the keywords hashmap
485    pub fn get_parameter_byte_metadata(
486        &self,
487        parameter_number: usize,
488        suffix: &str,
489    ) -> Result<&ByteKeyword> {
490        // Interpolate the parameter number into the keyword:
491        let keyword = format!("$P{parameter_number}{suffix}");
492        self.get_byte_keyword(&keyword)
493    }
494
495    /// Get excitation wavelength(s) for a parameter from `$PnL` keyword
496    /// Returns the first wavelength if multiple are present (for co-axial lasers)
497    /// # Errors
498    /// Will return `Err` if the keyword is not present in the keywords hashmap
499    pub fn get_parameter_excitation_wavelength(
500        &self,
501        parameter_number: usize,
502    ) -> Result<Option<usize>> {
503        let keyword = format!("$P{parameter_number}L");
504
505        // Try as integer keyword first (older FCS format)
506        if let Ok(int_keyword) = self.get_integer_keyword(&keyword) {
507            if let IntegerKeyword::PnL(wavelength) = int_keyword {
508                return Ok(Some(*wavelength));
509            }
510        }
511
512        // Try as mixed keyword (FCS 3.1+ format, can have multiple wavelengths)
513        if let Ok(mixed_keyword) = self.get_mixed_keyword(&keyword) {
514            if let MixedKeyword::PnL(wavelengths) = mixed_keyword {
515                // Return the first wavelength if multiple are present
516                return Ok(wavelengths.first().copied());
517            }
518        }
519
520        Ok(None)
521    }
522
523    /// Return the name of the parameter's channel from the `$PnN` keyword in the metadata TEXT section, where `n` is the provided parameter index (1-based)
524    /// # Errors
525    /// Will return `Err` if the keyword is not present in the keywords hashmap
526    pub fn get_parameter_channel_name(&self, parameter_number: usize) -> Result<&str> {
527        if let StringKeyword::PnN(name) =
528            self.get_parameter_string_metadata(parameter_number, "N")?
529        {
530            Ok(name.as_ref())
531        } else {
532            Err(anyhow!(
533                "$P{parameter_number}N keyword not found in metadata TEXT section",
534            ))
535        }
536    }
537
538    /// Return the label name of the parameter from the `$PnS` keyword in the metadata TEXT section, where `n` is the provided parameter number
539    /// # Errors
540    /// Will return `Err` if the keyword is not present in the keywords hashmap
541    pub fn get_parameter_label(&self, parameter_number: usize) -> Result<&str> {
542        if let StringKeyword::PnS(label) =
543            self.get_parameter_string_metadata(parameter_number, "S")?
544        {
545            Ok(label.as_ref())
546        } else {
547            Err(anyhow!(
548                "$P{parameter_number}S keyword not found in metadata TEXT section",
549            ))
550        }
551    }
552
553    /// Transform the metadata keywords hashmap into a JSON object via serde
554    /// # Errors
555    /// Will return `Err` if the metadata keywords hashmap is empty
556    pub fn get_metadata_as_json_string(&self) -> Result<String> {
557        if self.keywords.is_empty() {
558            Err(anyhow!("No metadata keywords stored."))
559        } else {
560            let json = serde_json::to_string(&self.keywords)?;
561            Ok(json)
562        }
563    }
564
565    /// Insert or update a string keyword in the metadata
566    pub fn insert_string_keyword(&mut self, key: String, value: String) {
567        let normalized_key = if key.starts_with('$') {
568            key
569        } else {
570            format!("${key}")
571        };
572
573        let parsed = match_and_parse_keyword(&normalized_key, value.as_str());
574        let string_keyword = match parsed {
575            KeywordCreationResult::String(string_keyword) => string_keyword,
576            // If parsing fails (or parses to a non-string keyword), fall back to `Other`.
577            _ => StringKeyword::Other(Arc::from(value)),
578        };
579
580        self.keywords
581            .insert(normalized_key, Keyword::String(string_keyword));
582    }
583
584    /// Create metadata from a DataFrame and ParameterMap
585    ///
586    /// This helper function creates all required FCS metadata keywords from scratch,
587    /// including file structure keywords ($BYTEORD, $DATATYPE, $MODE, $PAR, $TOT, $NEXTDATA)
588    /// and parameter-specific keywords ($PnN, $PnS, $PnB, $PnE, $PnR) for each parameter.
589    ///
590    /// # Arguments
591    /// * `df` - The DataFrame containing event data
592    /// * `params` - The ParameterMap containing parameter metadata
593    ///
594    /// # Returns
595    /// A new Metadata struct with all required keywords populated
596    pub fn from_dataframe_and_parameters(
597        df: &polars::prelude::DataFrame,
598        params: &super::parameter::ParameterMap,
599    ) -> Result<Self> {
600        let mut metadata = Self::new();
601        let n_events = df.height();
602        let n_params = df.width();
603
604        // Required file structure keywords
605        // BYTEORD - use LittleEndian as default (1,2,3,4)
606        metadata.keywords.insert(
607            "$BYTEORD".to_string(),
608            Keyword::Byte(ByteKeyword::BYTEORD(ByteOrder::LittleEndian)),
609        );
610
611        // DATATYPE - use F (float32) as default
612        metadata.keywords.insert(
613            "$DATATYPE".to_string(),
614            Keyword::Byte(ByteKeyword::DATATYPE(FcsDataType::F)),
615        );
616
617        // MODE
618        metadata.insert_string_keyword("$MODE".to_string(), "L".to_string());
619
620        // PAR
621        metadata.keywords.insert(
622            "$PAR".to_string(),
623            Keyword::Int(IntegerKeyword::PAR(n_params)),
624        );
625
626        // TOT
627        metadata.keywords.insert(
628            "$TOT".to_string(),
629            Keyword::Int(IntegerKeyword::TOT(n_events)),
630        );
631
632        // NEXTDATA
633        metadata.insert_string_keyword("$NEXTDATA".to_string(), "0".to_string());
634
635        // Add parameter keywords ($PnN, $PnS, $PnB, $PnE, $PnR)
636        // Get column names from DataFrame in order
637        let column_names = df.get_column_names();
638        for (param_idx, param_name) in column_names.iter().enumerate() {
639            let param_num = param_idx + 1;
640
641            // Get parameter from ParameterMap if available
642            // Convert PlSmallStr to Arc<str> for ParameterMap lookup
643            let param_name_arc: Arc<str> = Arc::from(param_name.as_str());
644            if let Some(param) = params.get(&param_name_arc) {
645                // $PnN - Parameter name
646                metadata.insert_string_keyword(format!("$P{}N", param_num), param_name.to_string());
647
648                // $PnS - Parameter label (short name)
649                metadata.insert_string_keyword(
650                    format!("$P{}S", param_num),
651                    param.label_name.to_string(),
652                );
653
654                // $PnB - Bits per parameter (default: 32 for float32)
655                metadata.keywords.insert(
656                    format!("$P{}B", param_num),
657                    Keyword::Int(IntegerKeyword::PnB(32)),
658                );
659
660                // $PnE - Amplification (default: 0,0)
661                metadata.insert_string_keyword(format!("$P{}E", param_num), "0,0".to_string());
662
663                // $PnR - Range (default: 262144)
664                metadata.keywords.insert(
665                    format!("$P{}R", param_num),
666                    Keyword::Int(IntegerKeyword::PnR(262144)),
667                );
668            } else {
669                // Fallback if parameter not in ParameterMap
670                metadata.insert_string_keyword(format!("$P{}N", param_num), param_name.to_string());
671                metadata.insert_string_keyword(format!("$P{}S", param_num), param_name.to_string());
672
673                metadata.keywords.insert(
674                    format!("$P{}B", param_num),
675                    Keyword::Int(IntegerKeyword::PnB(32)),
676                );
677
678                metadata.insert_string_keyword(format!("$P{}E", param_num), "0,0".to_string());
679
680                metadata.keywords.insert(
681                    format!("$P{}R", param_num),
682                    Keyword::Int(IntegerKeyword::PnR(262144)),
683                );
684            }
685        }
686
687        // Generate GUID
688        metadata.validate_guid();
689
690        Ok(metadata)
691    }
692}