Skip to main content

phasesmith_io/
powder.rs

1//! Deterministic readers for common text powder-pattern formats.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use phasesmith_model::{DomainError, PatternRecord, TofPatternRecord};
10
11type GsasBank = (Vec<String>, Vec<String>);
12type GsasBanks = BTreeMap<usize, GsasBank>;
13
14/// Caller-selected or detected powder text format.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum PowderFormat {
17    /// Detect GSAS bank headers or fall back to plain columns.
18    #[default]
19    Auto,
20    /// Two or three whitespace/comma-separated columns.
21    Columns,
22    /// Unpacked constant-wavelength GSAS FXYE bank.
23    GsasFxye,
24    /// Packed constant-step GSAS STD bank.
25    GsasStd,
26}
27
28/// Caller-selected or detected microsecond-domain powder text format.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum TofPowderFormat {
31    /// Detect a supported GSAS bank or fall back to center/density columns.
32    #[default]
33    Auto,
34    /// Two or three columns: bin-center TOF, intensity density, and optional sigma.
35    Columns,
36    /// GSAS logarithmic FXYE bin boundaries with width-multiplied values.
37    GsasSlogFxye,
38    /// GSAS packed constant-step lower-bin boundaries and integrated counts.
39    GsasConstStd,
40}
41
42/// Resource limits checked before or during parsing.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct PowderReadLimits {
45    /// Maximum UTF-8 byte count.
46    pub max_bytes: usize,
47    /// Maximum number of output samples.
48    pub max_rows: usize,
49}
50
51impl Default for PowderReadLimits {
52    fn default() -> Self {
53        Self {
54            max_bytes: 64 * 1024 * 1024,
55            max_rows: 5_000_000,
56        }
57    }
58}
59
60impl PowderReadLimits {
61    /// Validate positive byte and row limits.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`PowderIoError::InvalidLimits`] when either limit is zero.
66    pub fn validate(self) -> Result<(), PowderIoError> {
67        if self.max_bytes == 0 || self.max_rows == 0 {
68            return Err(PowderIoError::InvalidLimits);
69        }
70        Ok(())
71    }
72}
73
74/// One observed powder dataset plus source metadata.
75#[derive(Clone, Debug, PartialEq)]
76pub struct PowderData {
77    /// Validated observed pattern with zero supplied background.
78    pub pattern: PatternRecord,
79    /// Concrete detected/selected format.
80    pub format: PowderFormat,
81    /// Source path when read from a file.
82    pub source_path: Option<PathBuf>,
83    /// Selected GSAS bank, absent for columns.
84    pub bank: Option<usize>,
85}
86
87/// One observed TOF powder dataset with an explicitly microsecond-domain axis.
88#[derive(Clone, Debug, PartialEq)]
89pub struct TofPowderData {
90    /// Validated bin-center TOF density pattern; it cannot be passed as a CW pattern.
91    pub pattern: TofPatternRecord,
92    /// Concrete detected/selected input convention.
93    pub format: TofPowderFormat,
94    /// Source path when read from a file.
95    pub source_path: Option<PathBuf>,
96    /// Selected positive GSAS bank, absent for plain columns.
97    pub bank: Option<usize>,
98    /// True when the selected bank declares logarithmic `SLOG` spacing.
99    pub logarithmic_grid: bool,
100}
101
102/// Native powder input failure with stable categories.
103#[derive(Debug)]
104pub enum PowderIoError {
105    /// A configured resource limit is zero.
106    InvalidLimits,
107    /// A selected GSAS bank is zero.
108    InvalidBank,
109    /// Input exceeds the configured byte limit.
110    ByteLimitExceeded {
111        /// Observed byte count.
112        actual: u64,
113        /// Configured maximum.
114        maximum: usize,
115    },
116    /// Parsed rows exceed the configured maximum.
117    RowLimitExceeded {
118        /// Configured maximum.
119        maximum: usize,
120    },
121    /// Filesystem or UTF-8 reading failed.
122    Io(std::io::Error),
123    /// A line contains invalid syntax.
124    Parse {
125        /// One-based input line, or zero for bank-wide metadata.
126        line: usize,
127        /// Stable explanation.
128        message: String,
129    },
130    /// The requested bank is unavailable.
131    MissingBank {
132        /// Requested positive bank number.
133        requested: usize,
134        /// Available banks in numeric order.
135        available: Vec<usize>,
136    },
137    /// Parsed numeric arrays violate the domain model.
138    Domain(DomainError),
139}
140
141impl Display for PowderIoError {
142    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::InvalidLimits => formatter.write_str("powder read limits must be positive"),
145            Self::InvalidBank => formatter.write_str("bank must be a positive integer"),
146            Self::ByteLimitExceeded { actual, maximum } => {
147                write!(
148                    formatter,
149                    "powder input exceeds max_bytes: {actual} > {maximum}"
150                )
151            }
152            Self::RowLimitExceeded { maximum } => {
153                write!(formatter, "powder data exceeds max_rows: {maximum}")
154            }
155            Self::Io(error) => Display::fmt(error, formatter),
156            Self::Parse { line: 0, message } => formatter.write_str(message),
157            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
158            Self::MissingBank {
159                requested,
160                available,
161            } => {
162                let values = available
163                    .iter()
164                    .map(usize::to_string)
165                    .collect::<Vec<_>>()
166                    .join(", ");
167                write!(
168                    formatter,
169                    "GSAS bank {requested} not found; available banks: {}",
170                    if values.is_empty() { "none" } else { &values }
171                )
172            }
173            Self::Domain(error) => Display::fmt(error, formatter),
174        }
175    }
176}
177
178impl Error for PowderIoError {
179    fn source(&self) -> Option<&(dyn Error + 'static)> {
180        match self {
181            Self::Io(error) => Some(error),
182            Self::Domain(error) => Some(error),
183            _ => None,
184        }
185    }
186}
187
188/// Read and parse one bounded UTF-8 powder file.
189///
190/// # Errors
191///
192/// Returns [`PowderIoError`] for filesystem, limit, syntax, or domain errors.
193pub fn read_powder_file(
194    path: impl AsRef<Path>,
195    format: PowderFormat,
196    bank: usize,
197    limits: PowderReadLimits,
198) -> Result<PowderData, PowderIoError> {
199    limits.validate()?;
200    if bank == 0 {
201        return Err(PowderIoError::InvalidBank);
202    }
203    let path = path.as_ref();
204    let size = fs::metadata(path).map_err(PowderIoError::Io)?.len();
205    if size > u64::try_from(limits.max_bytes).unwrap_or(u64::MAX) {
206        return Err(PowderIoError::ByteLimitExceeded {
207            actual: size,
208            maximum: limits.max_bytes,
209        });
210    }
211    let text = fs::read_to_string(path).map_err(PowderIoError::Io)?;
212    parse_powder_text_inner(&text, format, bank, limits, Some(path.to_owned()))
213}
214
215/// Parse bounded UTF-8 powder text without filesystem access.
216///
217/// # Errors
218///
219/// Returns [`PowderIoError`] for limit, syntax, or domain errors.
220pub fn parse_powder_text(
221    text: &str,
222    format: PowderFormat,
223    bank: usize,
224    limits: PowderReadLimits,
225) -> Result<PowderData, PowderIoError> {
226    parse_powder_text_inner(text, format, bank, limits, None)
227}
228
229/// Read one bounded reduced TOF file into bin-center intensity densities.
230///
231/// The input convention is detected as plain center/density columns, GSAS SLOG
232/// FXYE boundaries, or packed constant-step GSAS STD counts.
233///
234/// # Errors
235///
236/// Returns [`PowderIoError`] for filesystem, limit, syntax, bank, or domain errors.
237pub fn read_tof_powder_file(
238    path: impl AsRef<Path>,
239    bank: usize,
240    limits: PowderReadLimits,
241) -> Result<TofPowderData, PowderIoError> {
242    read_tof_powder_file_as(path, TofPowderFormat::Auto, bank, limits)
243}
244
245/// Read one bounded microsecond-domain powder file with an explicit convention.
246///
247/// # Errors
248///
249/// Returns [`PowderIoError`] for filesystem, limit, syntax, bank, or domain errors.
250pub fn read_tof_powder_file_as(
251    path: impl AsRef<Path>,
252    format: TofPowderFormat,
253    bank: usize,
254    limits: PowderReadLimits,
255) -> Result<TofPowderData, PowderIoError> {
256    limits.validate()?;
257    if bank == 0 {
258        return Err(PowderIoError::InvalidBank);
259    }
260    let path = path.as_ref();
261    let size = fs::metadata(path).map_err(PowderIoError::Io)?.len();
262    if size > u64::try_from(limits.max_bytes).unwrap_or(u64::MAX) {
263        return Err(PowderIoError::ByteLimitExceeded {
264            actual: size,
265            maximum: limits.max_bytes,
266        });
267    }
268    let text = fs::read_to_string(path).map_err(PowderIoError::Io)?;
269    parse_tof_powder_text_inner(&text, format, bank, limits, Some(path.to_owned()))
270}
271
272/// Parse one bounded reduced TOF input into bin-center intensity densities.
273///
274/// # Errors
275///
276/// Returns [`PowderIoError`] for limit, syntax, bank, or domain errors.
277pub fn parse_tof_powder_text(
278    text: &str,
279    bank: usize,
280    limits: PowderReadLimits,
281) -> Result<TofPowderData, PowderIoError> {
282    parse_tof_powder_text_as(text, TofPowderFormat::Auto, bank, limits)
283}
284
285/// Parse bounded microsecond-domain powder text with an explicit convention.
286///
287/// # Errors
288///
289/// Returns [`PowderIoError`] for limit, syntax, bank, or domain errors.
290pub fn parse_tof_powder_text_as(
291    text: &str,
292    format: TofPowderFormat,
293    bank: usize,
294    limits: PowderReadLimits,
295) -> Result<TofPowderData, PowderIoError> {
296    parse_tof_powder_text_inner(text, format, bank, limits, None)
297}
298
299fn parse_tof_powder_text_inner(
300    text: &str,
301    format: TofPowderFormat,
302    bank: usize,
303    limits: PowderReadLimits,
304    source_path: Option<PathBuf>,
305) -> Result<TofPowderData, PowderIoError> {
306    limits.validate()?;
307    if bank == 0 {
308        return Err(PowderIoError::InvalidBank);
309    }
310    if text.len() > limits.max_bytes {
311        return Err(PowderIoError::ByteLimitExceeded {
312            actual: u64::try_from(text.len()).unwrap_or(u64::MAX),
313            maximum: limits.max_bytes,
314        });
315    }
316    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
317    let resolved = match format {
318        TofPowderFormat::Auto
319            if text
320                .lines()
321                .any(|line| line.trim_start().starts_with("BANK")) =>
322        {
323            let banks = gsas_banks(text)?;
324            let (header, _) = selected_bank(&banks, bank)?;
325            if header
326                .last()
327                .is_some_and(|value| value.eq_ignore_ascii_case("FXYE"))
328            {
329                TofPowderFormat::GsasSlogFxye
330            } else {
331                TofPowderFormat::GsasConstStd
332            }
333        }
334        TofPowderFormat::Auto => TofPowderFormat::Columns,
335        selected => selected,
336    };
337    match resolved {
338        TofPowderFormat::Columns => read_tof_columns(text, source_path, limits.max_rows),
339        TofPowderFormat::GsasConstStd => {
340            read_tof_gsas_std(text, source_path, bank, limits.max_rows)
341        }
342        TofPowderFormat::GsasSlogFxye => {
343            read_tof_gsas_slog_fxye(text, source_path, bank, limits.max_rows)
344        }
345        TofPowderFormat::Auto => unreachable!("auto TOF format is resolved above"),
346    }
347}
348
349fn read_tof_gsas_slog_fxye(
350    text: &str,
351    source_path: Option<PathBuf>,
352    bank: usize,
353    max_rows: usize,
354) -> Result<TofPowderData, PowderIoError> {
355    let banks = gsas_banks(text)?;
356    let (header, lines) = selected_bank(&banks, bank)?;
357    if header
358        .get(4)
359        .is_none_or(|value| !value.eq_ignore_ascii_case("SLOG"))
360        || header
361            .last()
362            .is_none_or(|value| !value.eq_ignore_ascii_case("FXYE"))
363    {
364        return Err(parse_error(0, "TOF input requires a GSAS SLOG FXYE bank"));
365    }
366    let declared_rows = header
367        .get(2)
368        .and_then(|value| value.parse::<usize>().ok())
369        .ok_or_else(|| parse_error(0, "invalid GSAS TOF bank dimensions"))?;
370    let rows = numeric_rows(&lines.join("\n"), Some(3), max_rows)?;
371    if rows.len() != declared_rows {
372        return Err(parse_error(
373            0,
374            format!(
375                "GSAS TOF bank contains {} rows; expected {declared_rows}",
376                rows.len()
377            ),
378        ));
379    }
380    if rows.len() < 2 {
381        return Err(parse_error(
382            0,
383            "GSAS TOF SLOG FXYE requires at least two bin-boundary rows",
384        ));
385    }
386    let output_count = rows.len() - 1;
387    let mut tof_us = Vec::with_capacity(output_count);
388    let mut observed_y = Vec::with_capacity(output_count);
389    let mut uncertainty = Vec::with_capacity(output_count);
390    let mut mask = Vec::with_capacity(output_count);
391    for pair in rows.windows(2) {
392        let row = &pair[0];
393        let next = &pair[1];
394        let width = next[0] - row[0];
395        if !width.is_finite() || width <= 0.0 {
396            return Err(parse_error(
397                0,
398                "GSAS TOF bin boundaries must be finite and strictly increasing",
399            ));
400        }
401        let supplied = row[2];
402        if supplied < 0.0 {
403            return Err(parse_error(0, "GSAS TOF uncertainty must be nonnegative"));
404        }
405        tof_us.push(0.5 * (row[0] + next[0]));
406        observed_y.push(row[1] / width);
407        uncertainty.push(if supplied == 0.0 {
408            1.0
409        } else {
410            supplied / width
411        });
412        mask.push(supplied != 0.0 && row[1] != 0.0);
413    }
414    let mask = mask.iter().any(|included| !included).then_some(mask);
415    let pattern = TofPatternRecord::new(tof_us, Some(observed_y), Some(uncertainty), mask, None)
416        .map_err(PowderIoError::Domain)?;
417    Ok(TofPowderData {
418        pattern,
419        format: TofPowderFormat::GsasSlogFxye,
420        source_path,
421        bank: Some(bank),
422        logarithmic_grid: true,
423    })
424}
425
426fn read_tof_columns(
427    text: &str,
428    source_path: Option<PathBuf>,
429    max_rows: usize,
430) -> Result<TofPowderData, PowderIoError> {
431    let rows = numeric_rows(text, None, max_rows)?;
432    let columns = rows
433        .first()
434        .map(Vec::len)
435        .ok_or_else(|| parse_error(0, "powder input contains no numeric rows"))?;
436    if !matches!(columns, 2 | 3) || rows.iter().any(|row| row.len() != columns) {
437        return Err(parse_error(
438            0,
439            "TOF columns require two or three consistent numeric fields",
440        ));
441    }
442    let uncertainty = (columns == 3).then(|| rows.iter().map(|row| row[2]).collect::<Vec<_>>());
443    if uncertainty
444        .as_deref()
445        .is_some_and(|values| values.iter().any(|value| *value <= 0.0))
446    {
447        return Err(parse_error(
448            0,
449            "TOF column uncertainty must be finite and positive",
450        ));
451    }
452    let pattern = TofPatternRecord::new(
453        rows.iter().map(|row| row[0]).collect(),
454        Some(rows.iter().map(|row| row[1]).collect()),
455        uncertainty,
456        None,
457        None,
458    )
459    .map_err(PowderIoError::Domain)?;
460    Ok(TofPowderData {
461        pattern,
462        format: TofPowderFormat::Columns,
463        source_path,
464        bank: None,
465        logarithmic_grid: false,
466    })
467}
468
469fn read_tof_gsas_std(
470    text: &str,
471    source_path: Option<PathBuf>,
472    bank: usize,
473    max_rows: usize,
474) -> Result<TofPowderData, PowderIoError> {
475    let banks = gsas_banks(text)?;
476    let (header, lines) = selected_bank(&banks, bank)?;
477    let encoding = if header.len() >= 10 {
478        header.last().map_or("", String::as_str)
479    } else {
480        "STD"
481    };
482    if header.len() < 7
483        || !header[4].eq_ignore_ascii_case("CONST")
484        || !encoding.eq_ignore_ascii_case("STD")
485    {
486        return Err(parse_error(
487            0,
488            "TOF input requires a packed constant-step GSAS STD bank",
489        ));
490    }
491    let row_count = header[2]
492        .parse::<usize>()
493        .map_err(|_| parse_error(0, "invalid packed GSAS TOF bank dimensions"))?;
494    let lower_us = parse_header_f64(&header[5])?;
495    let step_us = parse_header_f64(&header[6])?;
496    if row_count == 0 || row_count > max_rows || !lower_us.is_finite() || !step_us.is_finite() {
497        return Err(parse_error(
498            0,
499            "packed GSAS TOF bank dimensions exceed limits or are non-finite",
500        ));
501    }
502    if step_us <= 0.0 {
503        return Err(parse_error(0, "packed GSAS TOF step must be positive"));
504    }
505    let (counts, count_sigma) = packed_std_values(lines, row_count)?;
506    let mut tof_us = Vec::with_capacity(row_count);
507    let mut observed_y = Vec::with_capacity(row_count);
508    let mut uncertainty = Vec::with_capacity(row_count);
509    let mut mask = Vec::with_capacity(row_count);
510    for (index, (&count, &sigma)) in counts.iter().zip(&count_sigma).enumerate() {
511        let exact_index = u32::try_from(index)
512            .map_err(|_| parse_error(0, "packed GSAS TOF bank dimensions exceed numeric limits"))?;
513        tof_us.push(lower_us + (f64::from(exact_index) + 0.5) * step_us);
514        observed_y.push(count / step_us);
515        uncertainty.push(if count > 0.0 { sigma / step_us } else { 1.0 });
516        mask.push(count > 0.0);
517    }
518    let pattern = TofPatternRecord::new(
519        tof_us,
520        Some(observed_y),
521        Some(uncertainty),
522        mask.iter().any(|included| !included).then_some(mask),
523        None,
524    )
525    .map_err(PowderIoError::Domain)?;
526    Ok(TofPowderData {
527        pattern,
528        format: TofPowderFormat::GsasConstStd,
529        source_path,
530        bank: Some(bank),
531        logarithmic_grid: false,
532    })
533}
534
535fn parse_powder_text_inner(
536    text: &str,
537    format: PowderFormat,
538    bank: usize,
539    limits: PowderReadLimits,
540    source_path: Option<PathBuf>,
541) -> Result<PowderData, PowderIoError> {
542    limits.validate()?;
543    if bank == 0 {
544        return Err(PowderIoError::InvalidBank);
545    }
546    if text.len() > limits.max_bytes {
547        return Err(PowderIoError::ByteLimitExceeded {
548            actual: text.len() as u64,
549            maximum: limits.max_bytes,
550        });
551    }
552    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
553    let selected = match format {
554        PowderFormat::Auto => detect_format(text, source_path.as_deref()),
555        selected => selected,
556    };
557    match selected {
558        PowderFormat::Columns => read_columns(text, source_path, limits.max_rows),
559        PowderFormat::GsasFxye => read_gsas_fxye(text, source_path, bank, limits.max_rows),
560        PowderFormat::GsasStd => read_gsas_std(text, source_path, bank, limits.max_rows),
561        PowderFormat::Auto => unreachable!("auto format is resolved above"),
562    }
563}
564
565fn detect_format(text: &str, source: Option<&Path>) -> PowderFormat {
566    let headers = text
567        .lines()
568        .map(str::trim)
569        .filter(|line| line.to_ascii_uppercase().starts_with("BANK "))
570        .collect::<Vec<_>>();
571    if headers
572        .iter()
573        .filter_map(|header| header.split_whitespace().next_back())
574        .any(|encoding| encoding.eq_ignore_ascii_case("FXYE"))
575    {
576        return PowderFormat::GsasFxye;
577    }
578    if !headers.is_empty() {
579        return PowderFormat::GsasStd;
580    }
581    if source
582        .and_then(Path::extension)
583        .is_some_and(|extension| extension.eq_ignore_ascii_case("fxye"))
584    {
585        return PowderFormat::GsasFxye;
586    }
587    PowderFormat::Columns
588}
589
590fn numeric_rows(
591    text: &str,
592    required_columns: Option<usize>,
593    max_rows: usize,
594) -> Result<Vec<Vec<f64>>, PowderIoError> {
595    let mut rows = Vec::new();
596    let mut columns = required_columns;
597    for (index, raw) in text.lines().enumerate() {
598        let line_number = index + 1;
599        let mut line = raw.trim();
600        if line.is_empty() || line.starts_with(['#', '!', ';']) {
601            continue;
602        }
603        if let Some(offset) = line.find(['#', '!']) {
604            line = line[..offset].trim();
605        }
606        if line.is_empty() {
607            continue;
608        }
609        let fields = line.replace(',', " ");
610        let fields = fields.split_whitespace().collect::<Vec<_>>();
611        if columns.is_none() && !matches!(fields.len(), 2 | 3) {
612            return Err(parse_error(line_number, "expected two or three columns"));
613        }
614        let expected = *columns.get_or_insert(fields.len());
615        if fields.len() != expected {
616            return Err(parse_error(
617                line_number,
618                format!("expected {expected} columns, got {}", fields.len()),
619            ));
620        }
621        let row = fields
622            .iter()
623            .map(|field| {
624                field
625                    .parse::<f64>()
626                    .map_err(|_| parse_error(line_number, "non-numeric powder value"))
627            })
628            .collect::<Result<Vec<_>, _>>()?;
629        rows.push(row);
630        if rows.len() > max_rows {
631            return Err(PowderIoError::RowLimitExceeded { maximum: max_rows });
632        }
633    }
634    if rows.is_empty() {
635        return Err(parse_error(0, "powder data contains no numeric rows"));
636    }
637    Ok(rows)
638}
639
640fn powder_data(
641    x_deg: Vec<f64>,
642    observed_y: Vec<f64>,
643    uncertainty: Option<Vec<f64>>,
644    mask: Option<Vec<bool>>,
645    format: PowderFormat,
646    source_path: Option<PathBuf>,
647    bank: Option<usize>,
648) -> Result<PowderData, PowderIoError> {
649    if x_deg.is_empty() {
650        return Err(parse_error(0, "powder data contains no numeric rows"));
651    }
652    let pattern = PatternRecord::new(x_deg, Some(observed_y), uncertainty, mask, None)
653        .map_err(PowderIoError::Domain)?;
654    Ok(PowderData {
655        pattern,
656        format,
657        source_path,
658        bank,
659    })
660}
661
662fn read_columns(
663    text: &str,
664    source: Option<PathBuf>,
665    max_rows: usize,
666) -> Result<PowderData, PowderIoError> {
667    let rows = numeric_rows(text, None, max_rows)?;
668    let has_uncertainty = rows[0].len() == 3;
669    powder_data(
670        rows.iter().map(|row| row[0]).collect(),
671        rows.iter().map(|row| row[1]).collect(),
672        has_uncertainty.then(|| rows.iter().map(|row| row[2]).collect()),
673        None,
674        PowderFormat::Columns,
675        source,
676        None,
677    )
678}
679
680fn gsas_banks(text: &str) -> Result<GsasBanks, PowderIoError> {
681    let mut banks = BTreeMap::new();
682    let mut active = None;
683    for (index, raw) in text.lines().enumerate() {
684        let line = raw.trim();
685        if line.to_ascii_uppercase().starts_with("BANK ") {
686            let fields = line
687                .split_whitespace()
688                .map(str::to_owned)
689                .collect::<Vec<_>>();
690            let bank = fields
691                .get(1)
692                .and_then(|value| value.parse::<usize>().ok())
693                .ok_or_else(|| parse_error(index + 1, "invalid GSAS bank header"))?;
694            if banks.insert(bank, (fields, Vec::new())).is_some() {
695                return Err(parse_error(
696                    index + 1,
697                    format!("duplicate GSAS bank {bank}"),
698                ));
699            }
700            active = Some(bank);
701        } else if let Some(bank) = active {
702            banks
703                .get_mut(&bank)
704                .expect("active bank exists")
705                .1
706                .push(raw.to_owned());
707        }
708    }
709    Ok(banks)
710}
711
712fn selected_bank(banks: &GsasBanks, bank: usize) -> Result<&GsasBank, PowderIoError> {
713    banks.get(&bank).ok_or_else(|| PowderIoError::MissingBank {
714        requested: bank,
715        available: banks.keys().copied().collect(),
716    })
717}
718
719fn read_gsas_fxye(
720    text: &str,
721    source: Option<PathBuf>,
722    bank: usize,
723    max_rows: usize,
724) -> Result<PowderData, PowderIoError> {
725    let banks = gsas_banks(text)?;
726    if banks.values().any(|(header, _)| {
727        header
728            .last()
729            .is_none_or(|value| !value.eq_ignore_ascii_case("FXYE"))
730    }) {
731        return Err(parse_error(
732            0,
733            "only unpacked GSAS FXYE banks are supported",
734        ));
735    }
736    let (header, lines) = selected_bank(&banks, bank)?;
737    debug_assert!(
738        header
739            .last()
740            .is_some_and(|value| value.eq_ignore_ascii_case("FXYE"))
741    );
742    let rows = numeric_rows(&lines.join("\n"), Some(3), max_rows)?;
743    let mut uncertainty = Vec::with_capacity(rows.len());
744    let mut mask = Vec::with_capacity(rows.len());
745    for row in &rows {
746        let supplied = row[2];
747        uncertainty.push(if supplied == 0.0 { 1.0 } else { supplied });
748        mask.push(supplied != 0.0);
749    }
750    let mask = mask.iter().any(|included| !included).then_some(mask);
751    powder_data(
752        rows.iter().map(|row| row[0] / 100.0).collect(),
753        rows.iter().map(|row| row[1]).collect(),
754        Some(uncertainty),
755        mask,
756        PowderFormat::GsasFxye,
757        source,
758        Some(bank),
759    )
760}
761
762fn read_gsas_std(
763    text: &str,
764    source: Option<PathBuf>,
765    bank: usize,
766    max_rows: usize,
767) -> Result<PowderData, PowderIoError> {
768    let banks = gsas_banks(text)?;
769    let (header, lines) = selected_bank(&banks, bank)?;
770    let encoding = if header.len() >= 10 {
771        header.last().map_or("", String::as_str)
772    } else {
773        "STD"
774    };
775    if header.len() < 7
776        || !header[4].eq_ignore_ascii_case("CONST")
777        || !encoding.eq_ignore_ascii_case("STD")
778    {
779        return Err(parse_error(
780            0,
781            "only packed constant-step GSAS STD banks are supported",
782        ));
783    }
784    let row_count = header[2]
785        .parse::<usize>()
786        .map_err(|_| parse_error(0, "invalid packed GSAS STD bank dimensions"))?;
787    let start_deg = parse_header_f64(&header[5])? / 100.0;
788    let step_deg = parse_header_f64(&header[6])? / 100.0;
789    if row_count == 0 || row_count > max_rows || !start_deg.is_finite() || !step_deg.is_finite() {
790        return Err(parse_error(
791            0,
792            "packed GSAS STD bank dimensions exceed limits or are non-finite",
793        ));
794    }
795    if step_deg <= 0.0 {
796        return Err(parse_error(0, "packed GSAS STD step must be positive"));
797    }
798    let mut intensities = Vec::with_capacity(row_count);
799    let mut uncertainty = Vec::with_capacity(row_count);
800    'lines: for (index, line) in lines.iter().enumerate() {
801        for bytes in line.as_bytes().chunks(8) {
802            let record = std::str::from_utf8(bytes)
803                .map_err(|_| parse_error(index + 1, "invalid UTF-8 fixed-width record"))?;
804            if record.trim().is_empty() {
805                continue;
806            }
807            let normalization_field = record.get(..2).unwrap_or(record).trim();
808            let normalization = if normalization_field.is_empty() {
809                1_u32
810            } else {
811                normalization_field
812                    .parse::<u32>()
813                    .map_err(|_| parse_error(index + 1, "invalid fixed-width record"))?
814                    .max(1)
815            };
816            let parsed_intensity = record
817                .get(2..)
818                .unwrap_or("")
819                .trim()
820                .parse::<f64>()
821                .map_err(|_| parse_error(index + 1, "invalid fixed-width record"))?;
822            if !parsed_intensity.is_finite() {
823                return Err(parse_error(index + 1, "invalid fixed-width record"));
824            }
825            let intensity = parsed_intensity.max(0.0);
826            intensities.push(intensity);
827            uncertainty.push(if intensity > 0.0 {
828                (intensity / f64::from(normalization)).sqrt()
829            } else {
830                1.0
831            });
832            if intensities.len() == row_count {
833                break 'lines;
834            }
835        }
836    }
837    if intensities.len() != row_count {
838        return Err(parse_error(
839            0,
840            format!(
841                "packed GSAS STD bank contains {} records; expected {row_count}",
842                intensities.len()
843            ),
844        ));
845    }
846    powder_data(
847        coordinate_grid(start_deg, step_deg, row_count)?,
848        intensities,
849        Some(uncertainty),
850        None,
851        PowderFormat::GsasStd,
852        source,
853        Some(bank),
854    )
855}
856
857fn packed_std_values(
858    lines: &[String],
859    row_count: usize,
860) -> Result<(Vec<f64>, Vec<f64>), PowderIoError> {
861    let mut intensities = Vec::with_capacity(row_count);
862    let mut uncertainty = Vec::with_capacity(row_count);
863    'lines: for (index, line) in lines.iter().enumerate() {
864        for bytes in line.as_bytes().chunks(8) {
865            let record = std::str::from_utf8(bytes)
866                .map_err(|_| parse_error(index + 1, "invalid UTF-8 fixed-width record"))?;
867            if record.trim().is_empty() {
868                continue;
869            }
870            let normalization_field = record.get(..2).unwrap_or(record).trim();
871            let normalization = if normalization_field.is_empty() {
872                1_u32
873            } else {
874                normalization_field
875                    .parse::<u32>()
876                    .map_err(|_| parse_error(index + 1, "invalid fixed-width record"))?
877                    .max(1)
878            };
879            let parsed_intensity = record
880                .get(2..)
881                .unwrap_or("")
882                .trim()
883                .parse::<f64>()
884                .map_err(|_| parse_error(index + 1, "invalid fixed-width record"))?;
885            if !parsed_intensity.is_finite() {
886                return Err(parse_error(index + 1, "invalid fixed-width record"));
887            }
888            let intensity = parsed_intensity.max(0.0);
889            intensities.push(intensity);
890            uncertainty.push(if intensity > 0.0 {
891                (intensity / f64::from(normalization)).sqrt()
892            } else {
893                1.0
894            });
895            if intensities.len() == row_count {
896                break 'lines;
897            }
898        }
899    }
900    if intensities.len() != row_count {
901        return Err(parse_error(
902            0,
903            format!(
904                "packed GSAS STD bank contains {} records; expected {row_count}",
905                intensities.len()
906            ),
907        ));
908    }
909    Ok((intensities, uncertainty))
910}
911
912fn coordinate_grid(
913    start_deg: f64,
914    step_deg: f64,
915    row_count: usize,
916) -> Result<Vec<f64>, PowderIoError> {
917    (0..row_count)
918        .map(|index| {
919            let exact_index = u32::try_from(index).map_err(|_| {
920                parse_error(
921                    0,
922                    "packed GSAS STD bank dimensions exceed limits or are non-finite",
923                )
924            })?;
925            Ok(start_deg + step_deg * f64::from(exact_index))
926        })
927        .collect()
928}
929
930fn parse_header_f64(value: &str) -> Result<f64, PowderIoError> {
931    value
932        .parse::<f64>()
933        .map_err(|_| parse_error(0, "invalid packed GSAS STD bank dimensions"))
934}
935
936fn parse_error(line: usize, message: impl Into<String>) -> PowderIoError {
937    PowderIoError::Parse {
938        line,
939        message: message.into(),
940    }
941}