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(¤t_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(¤t_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(®ex_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(¶m_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}