Skip to main content

phasesmith_io/
tof_instrument.rs

1//! Bounded legacy GSAS TOF instrument-parameter import.
2//!
3//! Profile functions 1 and 3 are translated into the shared published
4//! back-to-back-exponential coefficient law. The adapter emits `PhaseSmith`'s
5//! typed 15-coefficient model; legacy records never enter the core.
6
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use phasesmith_core::{
13    TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT, TofBankGeometry, TofError, TofIncidentSpectrum,
14    TofInstrument,
15};
16
17/// Resource limit checked before decoding a legacy instrument file.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct GsasTofInstrumentReadLimits {
20    /// Maximum UTF-8 byte count.
21    pub max_bytes: usize,
22}
23
24impl Default for GsasTofInstrumentReadLimits {
25    fn default() -> Self {
26        Self {
27            max_bytes: 4 * 1024 * 1024,
28        }
29    }
30}
31
32impl GsasTofInstrumentReadLimits {
33    /// Reject a zero allocation boundary.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`GsasTofInstrumentIoError::InvalidLimits`] for zero bytes.
38    pub fn validate(self) -> Result<(), GsasTofInstrumentIoError> {
39        if self.max_bytes == 0 {
40            return Err(GsasTofInstrumentIoError::InvalidLimits);
41        }
42        Ok(())
43    }
44}
45
46/// One translated legacy GSAS TOF bank plus source metadata.
47#[derive(Clone, Debug, PartialEq)]
48pub struct GsasTofInstrumentData {
49    /// `PhaseSmith`'s validated public 15-coefficient model.
50    pub instrument: TofInstrument,
51    /// Selected positive legacy bank.
52    pub bank: usize,
53    /// Legacy GSAS profile function number. Currently 1 or 3.
54    pub profile_function: usize,
55    /// Parsed fixed bank scattering angle when a `BNKPAR` record is present.
56    pub bank_geometry: Option<TofBankGeometry>,
57    /// Parsed type-4 Maxwellian/Chebyshev incident spectrum when present.
58    pub incident_spectrum: Option<TofIncidentSpectrum>,
59    /// Source path when read from a file.
60    pub source_path: Option<PathBuf>,
61}
62
63/// Stable failure categories for legacy GSAS TOF instrument import.
64#[derive(Debug)]
65pub enum GsasTofInstrumentIoError {
66    /// The configured byte limit is zero.
67    InvalidLimits,
68    /// The selected bank is zero or too large for the two-column legacy key.
69    InvalidBank,
70    /// Input exceeds the configured byte limit.
71    ByteLimitExceeded {
72        /// Observed bytes.
73        actual: u64,
74        /// Configured maximum.
75        maximum: usize,
76    },
77    /// Filesystem or UTF-8 reading failed.
78    Io(std::io::Error),
79    /// A required bank record is absent.
80    MissingRecord {
81        /// Selected bank.
82        bank: usize,
83        /// Legacy record name.
84        record: &'static str,
85    },
86    /// A record does not contain the required finite values.
87    InvalidRecord {
88        /// Selected bank.
89        bank: usize,
90        /// Legacy record name.
91        record: &'static str,
92    },
93    /// Only independently documented legacy translations are supported.
94    UnsupportedProfileFunction {
95        /// Selected bank.
96        bank: usize,
97        /// Parsed legacy function number.
98        found: usize,
99    },
100    /// The selected bank declares an incident-spectrum function not translated here.
101    UnsupportedIncidentSpectrumFunction {
102        /// Selected legacy bank.
103        bank: usize,
104        /// Parsed legacy incident-spectrum function number.
105        found: usize,
106    },
107    /// Translated coefficients violate the core model.
108    Profile(TofError),
109}
110
111impl Display for GsasTofInstrumentIoError {
112    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::InvalidLimits => {
115                formatter.write_str("GSAS TOF instrument max_bytes must be positive")
116            }
117            Self::InvalidBank => formatter.write_str("GSAS TOF bank must lie in 1..=99"),
118            Self::ByteLimitExceeded { actual, maximum } => write!(
119                formatter,
120                "GSAS TOF instrument input exceeds max_bytes: {actual} > {maximum}"
121            ),
122            Self::Io(error) => Display::fmt(error, formatter),
123            Self::MissingRecord { bank, record } => {
124                write!(formatter, "GSAS TOF bank {bank} has no {record} record")
125            }
126            Self::InvalidRecord { bank, record } => {
127                write!(
128                    formatter,
129                    "GSAS TOF bank {bank} has an invalid {record} record"
130                )
131            }
132            Self::UnsupportedProfileFunction { bank, found } => write!(
133                formatter,
134                "GSAS TOF bank {bank} uses unsupported profile function {found}; only functions 1 and 3 are supported"
135            ),
136            Self::UnsupportedIncidentSpectrumFunction { bank, found } => write!(
137                formatter,
138                "GSAS TOF bank {bank} uses unsupported incident-spectrum function {found}; only functions 0 and 4 are supported"
139            ),
140            Self::Profile(error) => Display::fmt(error, formatter),
141        }
142    }
143}
144
145impl Error for GsasTofInstrumentIoError {
146    fn source(&self) -> Option<&(dyn Error + 'static)> {
147        match self {
148            Self::Io(error) => Some(error),
149            Self::Profile(error) => Some(error),
150            _ => None,
151        }
152    }
153}
154
155/// Read one bounded legacy GSAS TOF profile-function-1 or -3 bank.
156///
157/// # Errors
158///
159/// Returns [`GsasTofInstrumentIoError`] for filesystem, limit, bank, syntax,
160/// unsupported-profile, or translated-domain failures.
161pub fn read_gsas_tof_instrument_file(
162    path: impl AsRef<Path>,
163    bank: usize,
164    limits: GsasTofInstrumentReadLimits,
165) -> Result<GsasTofInstrumentData, GsasTofInstrumentIoError> {
166    validate_request(bank, limits)?;
167    let path = path.as_ref();
168    let size = fs::metadata(path)
169        .map_err(GsasTofInstrumentIoError::Io)?
170        .len();
171    if size > u64::try_from(limits.max_bytes).unwrap_or(u64::MAX) {
172        return Err(GsasTofInstrumentIoError::ByteLimitExceeded {
173            actual: size,
174            maximum: limits.max_bytes,
175        });
176    }
177    let text = fs::read_to_string(path).map_err(GsasTofInstrumentIoError::Io)?;
178    parse_inner(&text, bank, limits, Some(path.to_owned()))
179}
180
181/// Parse one bounded legacy GSAS TOF profile-function-1 or -3 bank from text.
182///
183/// # Errors
184///
185/// Returns [`GsasTofInstrumentIoError`] for limit, bank, syntax,
186/// unsupported-profile, or translated-domain failures.
187pub fn parse_gsas_tof_instrument_text(
188    text: &str,
189    bank: usize,
190    limits: GsasTofInstrumentReadLimits,
191) -> Result<GsasTofInstrumentData, GsasTofInstrumentIoError> {
192    parse_inner(text, bank, limits, None)
193}
194
195fn parse_inner(
196    text: &str,
197    bank: usize,
198    limits: GsasTofInstrumentReadLimits,
199    source_path: Option<PathBuf>,
200) -> Result<GsasTofInstrumentData, GsasTofInstrumentIoError> {
201    validate_request(bank, limits)?;
202    if text.len() > limits.max_bytes {
203        return Err(GsasTofInstrumentIoError::ByteLimitExceeded {
204            actual: u64::try_from(text.len()).unwrap_or(u64::MAX),
205            maximum: limits.max_bytes,
206        });
207    }
208    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
209    let icons = record_values(text, bank, "ICONS", &format!("INS {bank:>2} ICONS"))?;
210    if icons.len() < 4 {
211        return Err(invalid(bank, "ICONS"));
212    }
213    let bank_geometry = parse_optional_bank_geometry(text, bank)?;
214    let incident_spectrum = parse_optional_incident_spectrum(text, bank)?;
215    let compact_header = format!("INS {bank:>2}PRCF1 ");
216    let spaced_header = format!("INS {bank:>2}PRCF  ");
217    let compact = text.lines().any(|line| line.starts_with(&compact_header));
218    let function = profile_function(
219        text,
220        bank,
221        if compact {
222            &compact_header
223        } else {
224            &spaced_header
225        },
226    )?;
227    if !matches!(function, 1 | 3) {
228        return Err(GsasTofInstrumentIoError::UnsupportedProfileFunction {
229            bank,
230            found: function,
231        });
232    }
233    let (exponential_prefix, gaussian_prefix) = if compact {
234        (
235            format!("INS {bank:>2}PRCF11"),
236            format!("INS {bank:>2}PRCF12"),
237        )
238    } else {
239        (
240            format!("INS {bank:>2}PRCF 1"),
241            format!("INS {bank:>2}PRCF 2"),
242        )
243    };
244    let exponential = record_values(text, bank, "PRCF11", &exponential_prefix)?;
245    let gaussian = record_values(text, bank, "PRCF12", &gaussian_prefix)?;
246    if exponential.len() < if function == 1 { 4 } else { 3 } {
247        return Err(invalid(bank, "PRCF11"));
248    }
249    if gaussian.len() < if function == 1 { 3 } else { 2 } {
250        return Err(invalid(bank, "PRCF12"));
251    }
252
253    // Legacy ICONS stores DIFC, DIFA, Zero, and an unused fourth field. It
254    // does not carry DIFB. Function 1 places an unused coefficient before
255    // alpha/beta0/beta1 and before sigma1/sigma2; function 3 starts each
256    // group directly. Remaining typed coefficients are explicit zeros.
257    let (alpha, beta0, beta1, sigma1, sigma2) = if function == 1 {
258        (
259            exponential[1],
260            exponential[2],
261            exponential[3],
262            gaussian[1],
263            gaussian[2],
264        )
265    } else {
266        (
267            exponential[0],
268            exponential[1],
269            exponential[2],
270            gaussian[0],
271            gaussian[1],
272        )
273    };
274    let instrument = TofInstrument {
275        zero_us: icons[2],
276        difc_us_per_angstrom: icons[0],
277        difa_us_per_angstrom2: icons[1],
278        difb_us_angstrom: 0.0,
279        alpha_coefficient: alpha,
280        beta0_per_us: beta0,
281        beta1_angstrom4_per_us: beta1,
282        betaq_angstrom2_per_us: 0.0,
283        sigma0_us2: 0.0,
284        sigma1_us2_per_angstrom2: sigma1,
285        sigma2_us2_per_angstrom4: sigma2,
286        sigmaq_us2_per_angstrom: 0.0,
287        x_us_per_angstrom: 0.0,
288        y_us_per_angstrom2: 0.0,
289        z_us: 0.0,
290    };
291    instrument
292        .validate()
293        .map_err(GsasTofInstrumentIoError::Profile)?;
294    Ok(GsasTofInstrumentData {
295        instrument,
296        bank,
297        profile_function: function,
298        bank_geometry,
299        incident_spectrum,
300        source_path,
301    })
302}
303
304fn validate_request(
305    bank: usize,
306    limits: GsasTofInstrumentReadLimits,
307) -> Result<(), GsasTofInstrumentIoError> {
308    limits.validate()?;
309    if !(1..=99).contains(&bank) {
310        return Err(GsasTofInstrumentIoError::InvalidBank);
311    }
312    Ok(())
313}
314
315fn record_values(
316    text: &str,
317    bank: usize,
318    record: &'static str,
319    prefix: &str,
320) -> Result<Vec<f64>, GsasTofInstrumentIoError> {
321    let line = text
322        .lines()
323        .find(|line| line.starts_with(prefix))
324        .ok_or(GsasTofInstrumentIoError::MissingRecord { bank, record })?;
325    let values = line[prefix.len()..]
326        .split_whitespace()
327        .map(str::parse::<f64>)
328        .collect::<Result<Vec<_>, _>>()
329        .map_err(|_| invalid(bank, record))?;
330    if values.is_empty() || values.iter().any(|value| !value.is_finite()) {
331        return Err(invalid(bank, record));
332    }
333    Ok(values)
334}
335
336fn optional_record_values(
337    text: &str,
338    bank: usize,
339    record: &'static str,
340    prefix: &str,
341) -> Result<Option<Vec<f64>>, GsasTofInstrumentIoError> {
342    let Some(line) = text.lines().find(|line| line.starts_with(prefix)) else {
343        return Ok(None);
344    };
345    let values = line[prefix.len()..]
346        .split_whitespace()
347        .map(str::parse::<f64>)
348        .collect::<Result<Vec<_>, _>>()
349        .map_err(|_| invalid(bank, record))?;
350    if values.is_empty() || values.iter().any(|value| !value.is_finite()) {
351        return Err(invalid(bank, record));
352    }
353    Ok(Some(values))
354}
355
356fn parse_optional_bank_geometry(
357    text: &str,
358    bank: usize,
359) -> Result<Option<TofBankGeometry>, GsasTofInstrumentIoError> {
360    optional_record_values(text, bank, "BNKPAR", &format!("INS {bank:>2}BNKPAR"))?
361        .map(|values| {
362            if values.len() < 2 {
363                return Err(invalid(bank, "BNKPAR"));
364            }
365            let geometry = TofBankGeometry {
366                two_theta_deg: values[1],
367            };
368            geometry.validate().map_err(|_| invalid(bank, "BNKPAR"))?;
369            Ok(geometry)
370        })
371        .transpose()
372}
373
374fn parse_optional_incident_spectrum(
375    text: &str,
376    bank: usize,
377) -> Result<Option<TofIncidentSpectrum>, GsasTofInstrumentIoError> {
378    let prefix = format!("INS {bank:>2}I ITYP");
379    let Some(line) = text.lines().find(|line| line.starts_with(&prefix)) else {
380        return Ok(None);
381    };
382    let mut tokens = line[prefix.len()..].split_whitespace();
383    let function = tokens
384        .next()
385        .ok_or_else(|| invalid(bank, "I ITYP"))?
386        .parse::<usize>()
387        .map_err(|_| invalid(bank, "I ITYP"))?;
388    let min_tof_ms = tokens
389        .next()
390        .ok_or_else(|| invalid(bank, "I ITYP"))?
391        .parse::<f64>()
392        .map_err(|_| invalid(bank, "I ITYP"))?;
393    let max_tof_ms = tokens
394        .next()
395        .ok_or_else(|| invalid(bank, "I ITYP"))?
396        .parse::<f64>()
397        .map_err(|_| invalid(bank, "I ITYP"))?;
398    if function == 0 {
399        return Ok(None);
400    }
401    if function != 4 {
402        return Err(
403            GsasTofInstrumentIoError::UnsupportedIncidentSpectrumFunction {
404                bank,
405                found: function,
406            },
407        );
408    }
409    let mut coefficients = Vec::with_capacity(TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT);
410    for record_index in 1..=3 {
411        coefficients.extend(record_values(
412            text,
413            bank,
414            "ICOFF",
415            &format!("INS {bank:>2}ICOFF{record_index}"),
416        )?);
417    }
418    if coefficients.len() != TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT {
419        return Err(invalid(bank, "ICOFF"));
420    }
421    let coefficients: [f64; TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT] = coefficients
422        .try_into()
423        .map_err(|_| invalid(bank, "ICOFF"))?;
424    TofIncidentSpectrum::new(min_tof_ms * 1_000.0, max_tof_ms * 1_000.0, coefficients)
425        .map(Some)
426        .map_err(|_| invalid(bank, "I ITYP"))
427}
428
429fn profile_function(
430    text: &str,
431    bank: usize,
432    prefix: &str,
433) -> Result<usize, GsasTofInstrumentIoError> {
434    let line = text.lines().find(|line| line.starts_with(prefix)).ok_or(
435        GsasTofInstrumentIoError::MissingRecord {
436            bank,
437            record: "PRCF1",
438        },
439    )?;
440    let mut tokens = line[prefix.len()..].split_whitespace();
441    let function = tokens
442        .next()
443        .ok_or_else(|| invalid(bank, "PRCF1"))?
444        .parse::<usize>()
445        .map_err(|_| invalid(bank, "PRCF1"))?;
446    Ok(function)
447}
448
449const fn invalid(bank: usize, record: &'static str) -> GsasTofInstrumentIoError {
450    GsasTofInstrumentIoError::InvalidRecord { bank, record }
451}