rexafs 0.2.14

Rust-powered X-ray absorption spectroscopy analysis and EXAFS fitting
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
//! Error types for XAFS analysis operations.
//!
//! This module defines domain-specific error types using the `thiserror` crate
//! for better error handling throughout the library.

use enterpolation::linear::LinearError;
use thiserror::Error;

/// Errors related to data validation and input processing.
#[derive(Error, Debug, Clone)]
pub enum DataError {
    /// A constant energy-axis correction cannot be represented safely.
    #[error("invalid energy offset {offset_ev} eV: {reason}")]
    InvalidEnergyOffset {
        /// Requested total energy offset, in eV.
        offset_ev: f64,
        /// Nonfinite input, overflow or loss of energy-grid spacing.
        reason: String,
    },

    #[error("insufficient data: need at least {min} points, got {actual}")]
    /// The operation received fewer samples than its minimum requirement.
    InsufficientData {
        /// Minimum required sample count.
        min: usize,
        /// Actual sample count.
        actual: usize,
    },

    #[error("data array length mismatch: energy has {energy_len} points, mu has {mu_len} points")]
    /// Paired arrays or related lists have different lengths. Some tools reuse these field names for non-energy arrays.
    LengthMismatch {
        /// Length of the first array/list, normally energy.
        energy_len: usize,
        /// Length of the second array/list, normally absorption.
        mu_len: usize,
    },

    #[error("invalid energy range: min={min}, max={max}")]
    /// The selected energy bounds do not describe a usable interval.
    InvalidEnergyRange {
        /// Lower energy bound, in eV.
        min: f64,
        /// Upper energy bound, in eV.
        max: f64,
    },

    #[error(
        "energy must be monotonic non-decreasing, but energy[{index}]={curr} is smaller than previous value {prev}"
    )]
    /// An energy sample is smaller than its predecessor; reorder paired energy and absorption together.
    NonMonotonicEnergy {
        /// Zero-based failing index.
        index: usize,
        /// Previous energy sample, in eV.
        prev: f64,
        /// Current energy sample, in eV.
        curr: f64,
    },

    #[error("duplicate energy at index {index}: {energy}; expected strictly increasing energy")]
    /// The checked constructor found repeated energy; combine or remove duplicate measurements explicitly.
    DuplicateEnergy {
        /// Zero-based failing index.
        index: usize,
        /// Repeated energy value, in eV.
        energy: f64,
    },

    #[error("data contains non-finite values at indices: {indices:?}")]
    /// Input validation found NaN or infinite values; indices refer to the original paired samples.
    NonFiniteValues {
        /// Zero-based indices containing non-finite values.
        indices: Vec<usize>,
    },

    #[error("missing required data: {field}")]
    /// A required array, stage result or configuration value is unavailable.
    MissingData {
        /// Missing field or result name.
        field: String,
    },

    #[error("index out of range: index={index}, length={length}")]
    /// A checked collection/list operation received an invalid zero-based index.
    IndexOutOfRange {
        /// Zero-based failing index.
        index: usize,
        /// Number of elements in the collection.
        length: usize,
    },

    #[error("group is empty")]
    /// The requested operation needs at least one spectrum.
    EmptyGroup,

    #[error("feature not implemented: {feature}")]
    /// The selected feature exists as a placeholder and has no implementation.
    NotImplemented {
        /// Name of the unimplemented feature.
        feature: String,
    },
}

/// Errors related to pre/post-edge normalization operations.
#[derive(Error, Debug, Clone)]
pub enum NormalizationError {
    #[error("edge energy (e0={e0}) is outside data range [{data_min}, {data_max}]")]
    /// The edge energy is non-finite or not strictly inside the measured energy interval.
    E0OutOfRange {
        /// Configured edge energy, in eV.
        e0: f64,
        /// Minimum measured energy, in eV.
        data_min: f64,
        /// Maximum measured energy, in eV.
        data_max: f64,
    },

    #[error("pre-edge fitting failed: not enough points in range [{start}, {end}]")]
    /// The selected absolute pre-edge interval contains insufficient usable points for a baseline fit.
    PreEdgeFitFailed {
        /// Requested absolute start energy, in eV.
        start: f64,
        /// Requested absolute end energy, in eV.
        end: f64,
    },

    #[error("post-edge fitting failed: polynomial order {order} too high for {n_points} points")]
    /// The requested post-edge polynomial cannot be determined from the available points.
    PostEdgeFitFailed {
        /// Requested polynomial degree.
        order: usize,
        /// Number of usable selected points.
        n_points: usize,
    },

    #[error("edge step is too small: {edge_step} (minimum: {min})")]
    /// The normalization scale is too small for stable division; inspect the selected edge and fitting ranges.
    EdgeStepTooSmall {
        /// Estimated or configured scale, in the input absorption units.
        edge_step: f64,
        /// Minimum accepted scale in the input absorption units.
        min: f64,
    },

    #[error("normalization method not implemented: {method}")]
    /// The chosen normalization method is a placeholder; no default algorithm was substituted.
    NotImplemented {
        /// Name of the selected normalization method.
        method: String,
    },

    #[error("normalization failed: {message}")]
    /// A normalization failure represented by its diagnostic text, including converted lower-level errors.
    Other {
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },
}

/// Errors related to AUTOBK background removal algorithm.
#[derive(Error, Debug, Clone)]
pub enum BackgroundError {
    #[error("AUTOBK optimization failed: {reason}")]
    /// The background optimizer failed; the diagnostic describes the solver or objective failure.
    OptimizationFailed {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("AUTOBK direct linear solver failed: {reason}")]
    /// The direct spline-coefficient solve failed numerically.
    DirectSolverFailed {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error(
        "AUTOBK direct solver rejected ill-conditioned system: condition proxy {condition_proxy} exceeds limit {limit}"
    )]
    /// A legacy direct-solver conditioning check exceeded its configured limit. The proxy is not an exact matrix condition number.
    DirectSolverIllConditioned {
        /// Dimensionless conditioning proxy reported by the legacy solve.
        condition_proxy: f64,
        /// Configured maximum acceptable conditioning proxy.
        limit: f64,
    },

    #[error("invalid rbkg parameter: {rbkg} (must be > 0)")]
    /// The requested low-R background cutoff is invalid.
    InvalidRbkg {
        /// Requested background cutoff, in Å.
        rbkg: f64,
    },

    #[error("spline knot calculation failed: insufficient k-range [{kmin}, {kmax}]")]
    /// The selected wave-number range cannot support the requested spline basis.
    SplineKnotsFailed {
        /// Wave-number interval start, in Å⁻¹; can be zero when the range could not be resolved.
        kmin: f64,
        /// Wave-number interval end, in Å⁻¹; can be zero when the range could not be resolved.
        kmax: f64,
    },

    #[error("Levenberg-Marquardt did not converge after {iterations} iterations")]
    /// A Levenberg–Marquardt background solve reached its reported iteration limit without convergence.
    ConvergenceFailure {
        /// Number of optimizer iterations reported at failure.
        iterations: usize,
    },

    #[error("background removal feature not implemented: {feature}")]
    /// The selected background feature is not implemented; no alternative is silently chosen.
    NotImplemented {
        /// Name of the unimplemented feature.
        feature: String,
    },

    #[error("background calculation failed: {message}")]
    /// A background failure represented by text, including converted data, normalization or interpolation errors.
    Other {
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },
}

/// Errors related to Fourier transform operations.
#[derive(Error, Debug, Clone)]
pub enum FFTError {
    #[error("invalid FFT parameter {parameter}: {reason}")]
    /// A Fourier setting is invalid or incompatible with the supplied grid.
    InvalidParameter {
        /// Name of the invalid Fourier setting.
        parameter: String,
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("FFT requires at least {min} points for k-range [{kmin}, {kmax}], got {actual}")]
    /// The forward transform received too few wave-number samples.
    InsufficientPoints {
        /// Minimum required sample count.
        min: usize,
        /// Actual sample count.
        actual: usize,
        /// Wave-number interval start, in Å⁻¹; can be zero when the range could not be resolved.
        kmin: f64,
        /// Wave-number interval end, in Å⁻¹; can be zero when the range could not be resolved.
        kmax: f64,
    },

    #[error("invalid FFT window: {window}")]
    /// The requested Fourier window is not supported by the operation.
    InvalidWindow {
        /// Requested window name.
        window: String,
    },

    #[error("IFFT failed: chi(R) array has {actual} points, expected {expected}")]
    /// The stored complex Fourier representation has the wrong length for the inverse transform.
    IFFTSizeMismatch {
        /// Required number of complex Fourier samples.
        expected: usize,
        /// Actual number of complex Fourier samples.
        actual: usize,
    },

    #[error("interpolation failed: {reason}")]
    /// Fourier grid preparation or paired input validation failed.
    InterpolationFailed {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("window function calculation failed: {reason}")]
    /// The window could not be evaluated for the supplied grid/settings.
    WindowCalculationFailed {
        /// Human-readable failure reason.
        reason: String,
    },
}

/// Errors related to file I/O and serialization operations.
#[derive(Error, Debug, Clone)]
pub enum IOError {
    #[error("file not found: {path}")]
    /// The requested file could not be found.
    FileNotFound {
        /// Filesystem path involved in the operation.
        path: String,
    },

    #[error("failed to read file {path}: {kind}")]
    /// Opening or reading a file failed. Legacy JSON/BSON writers also use this variant for file-creation failures.
    ReadFailed {
        /// Filesystem path involved in the operation.
        path: String,
        /// Underlying operating-system I/O error category.
        kind: std::io::ErrorKind,
    },

    #[error("JSON deserialization failed: {message}")]
    /// JSON serialization or deserialization failed; the legacy display label mentions only deserialization.
    JsonError {
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },

    #[error("BSON deserialization failed: {message}")]
    /// BSON serialization or deserialization failed; the legacy display label mentions only deserialization.
    BsonError {
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },

    #[error("compression error: {message}")]
    /// Compression or decompression failed, including malformed compressed input.
    CompressionError {
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },

    #[error("failed to write file {path}: {kind}")]
    /// Writing, creating or flushing an output file failed.
    WriteFailed {
        /// Filesystem path involved in the operation.
        path: String,
        /// Underlying operating-system I/O error category.
        kind: std::io::ErrorKind,
    },

    #[error("not an Athena project file: {reason}")]
    /// The input does not identify a supported Athena project.
    NotAthenaProject {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("Athena project parse error at line {line}: {message}")]
    /// An Athena project statement could not be parsed at the reported source line.
    AthenaParse {
        /// One-based source line number.
        line: usize,
        /// Human-readable diagnostic from the failing operation.
        message: String,
    },

    #[error("cannot export to Athena project: {reason}")]
    /// Spectrum content cannot be represented by the Athena exporter.
    AthenaExport {
        /// Human-readable failure reason.
        reason: String,
    },
}

/// Errors related to mathematical operations.
#[derive(Error, Debug, Clone)]
pub enum MathError {
    #[error("interpolation failed: x value {x} is outside range [{xmin}, {xmax}]")]
    /// A query falls outside the interpolation interval for an operation that forbids extrapolation.
    InterpolationOutOfBounds {
        /// Query coordinate in the units of the interpolation/spline input.
        x: f64,
        /// Lower interpolation bound in the query coordinate units.
        xmin: f64,
        /// Upper interpolation bound in the query coordinate units.
        xmax: f64,
    },

    #[error("polynomial fit failed: {reason}")]
    /// A polynomial fit failed, for example because its coordinates are degenerate.
    PolyfitFailed {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("spline evaluation failed at x={x}: {reason}")]
    /// Spline construction/evaluation failed. Some wrappers use x = 0 as a placeholder rather than a failing sample.
    SplineEvalFailed {
        /// Query coordinate in the units of the interpolation/spline input.
        x: f64,
        /// Human-readable failure reason.
        reason: String,
    },

    #[error("index {index} out of bounds for array of length {len}")]
    /// An index is invalid for the given array length.
    IndexOutOfBounds {
        /// Zero-based failing index.
        index: usize,
        /// Number of elements in the array or collection.
        len: usize,
    },
}

impl From<MathError> for NormalizationError {
    fn from(value: MathError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<DataError> for NormalizationError {
    fn from(value: DataError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<&'static str> for NormalizationError {
    fn from(value: &'static str) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<Box<dyn std::error::Error>> for NormalizationError {
    fn from(value: Box<dyn std::error::Error>) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<LinearError> for NormalizationError {
    fn from(value: LinearError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<MathError> for BackgroundError {
    fn from(value: MathError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<DataError> for BackgroundError {
    fn from(value: DataError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<NormalizationError> for BackgroundError {
    fn from(value: NormalizationError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<LinearError> for BackgroundError {
    fn from(value: LinearError) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

impl From<Box<dyn std::error::Error>> for BackgroundError {
    fn from(value: Box<dyn std::error::Error>) -> Self {
        Self::Other {
            message: value.to_string(),
        }
    }
}

/// Errors raised by the linear-combination-fitting / PCA analysis module.
#[derive(Error, Debug, Clone)]
pub enum AnalysisError {
    /// Normalization or background subtraction failed on a temporary input copy.
    #[error("could not prepare {spectrum}: {source}")]
    Preparation {
        /// Input name, or an explicit unnamed-input label.
        spectrum: String,
        /// Original processing error.
        #[source]
        source: Box<super::XAFSError>,
    },
    /// Analysis data or their edge origin are not usable.
    #[error("invalid analysis input {spectrum}: {reason}")]
    InvalidInput {
        /// Input name.
        spectrum: String,
        /// Contextual validation failure.
        reason: String,
    },
    /// Bounds must be finite and strictly increasing; they are never swapped.
    #[error("analysis range [{lo}, {hi}] must be finite and increasing")]
    InvalidRange {
        /// Requested lower bound, in axis units or energy offsets.
        lo: f64,
        /// Requested upper bound, in the same units.
        hi: f64,
    },
    /// The entire requested interval must be measured; extrapolation is disabled.
    #[error("{spectrum} covers [{available_lo}, {available_hi}], not the requested [{lo}, {hi}]; extrapolation is disabled")]
    IncompleteCoverage {
        /// Input name.
        spectrum: String,
        /// Requested absolute lower bound, in eV or Å⁻¹.
        lo: f64,
        /// Requested absolute upper bound.
        hi: f64,
        /// First available axis value.
        available_lo: f64,
        /// Last available axis value.
        available_hi: f64,
    },

    #[error("missing array for analysis: {field}")]
    /// The chosen analysis space requires an array that has not been calculated.
    MissingArray {
        /// Missing field or result name.
        field: String,
    },

    #[error("no standards / spectra supplied")]
    /// No standards or training spectra were supplied.
    NoSpectra,

    #[error("insufficient spectra: need at least {min}, got {actual}")]
    /// Too few spectra were supplied for the analysis.
    InsufficientSpectra {
        /// Minimum required spectrum count.
        min: usize,
        /// Number of supplied spectra.
        actual: usize,
    },

    #[error("fit range [{lo}, {hi}] selects only {n_points} points (need at least {min})")]
    /// The selected analysis interval contains fewer usable points than required.
    EmptyRange {
        /// Lower bound in the selected analysis-axis units (eV or Å⁻¹).
        lo: f64,
        /// Upper bound in the selected analysis-axis units (eV or Å⁻¹).
        hi: f64,
        /// Number of usable selected points.
        n_points: usize,
        /// Minimum required sample count.
        min: usize,
    },

    #[error(
        "weight constraints are infeasible: sum-to-one target {target} not within [{lo}, {hi}]"
    )]
    /// The sum-to-one target lies outside the combined permitted weight bounds.
    InfeasibleConstraints {
        /// Required dimensionless sum of component weights.
        target: f64,
        /// Sum of component lower weight bounds, dimensionless.
        lo: f64,
        /// Sum of component upper weight bounds, dimensionless.
        hi: f64,
    },

    #[error("{count} standard combinations requested, exceeding the cap of {max}")]
    /// The requested combinatorial search exceeds the configured cap.
    TooManyCombinations {
        /// Number of candidate combinations.
        count: usize,
        /// Configured maximum number of candidate combinations.
        max: usize,
    },

    #[error("requested {requested} components but the model has only {available}")]
    /// The requested reconstruction rank exceeds the stored component count.
    TooManyComponents {
        /// Requested number of components.
        requested: usize,
        /// Number of components present in the model.
        available: usize,
    },

    #[error("linear algebra failed: {reason}")]
    /// The matrix solve or decomposition failed numerically.
    LinearAlgebra {
        /// Human-readable failure reason.
        reason: String,
    },

    #[error(transparent)]
    /// A typed lower-level spectrum error propagated into the analysis.
    Xafs(#[from] super::XAFSError),
}

impl From<DataError> for AnalysisError {
    fn from(value: DataError) -> Self {
        Self::Xafs(value.into())
    }
}

impl From<MathError> for AnalysisError {
    fn from(value: MathError) -> Self {
        Self::Xafs(value.into())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_data_error_insufficient_data() {
        let error = DataError::InsufficientData {
            min: 100,
            actual: 50,
        };
        let msg = error.to_string();
        assert!(msg.contains("100"));
        assert!(msg.contains("50"));
        assert!(msg.contains("insufficient data"));
    }

    #[test]
    fn test_data_error_length_mismatch() {
        let error = DataError::LengthMismatch {
            energy_len: 100,
            mu_len: 95,
        };
        let msg = error.to_string();
        assert!(msg.contains("100"));
        assert!(msg.contains("95"));
        assert!(msg.contains("mismatch"));
    }

    #[test]
    fn test_normalization_error_e0_out_of_range() {
        let error = NormalizationError::E0OutOfRange {
            e0: 8000.0,
            data_min: 8100.0,
            data_max: 8500.0,
        };
        let msg = error.to_string();
        assert!(msg.contains("8000"));
        assert!(msg.contains("8100"));
        assert!(msg.contains("8500"));
    }

    #[test]
    fn test_background_error_convergence_failure() {
        let error = BackgroundError::ConvergenceFailure { iterations: 500 };
        let msg = error.to_string();
        assert!(msg.contains("500"));
        assert!(msg.contains("converge"));
    }

    #[test]
    fn test_fft_error_insufficient_points() {
        let error = FFTError::InsufficientPoints {
            min: 64,
            actual: 32,
            kmin: 0.0,
            kmax: 15.0,
        };
        let msg = error.to_string();
        assert!(msg.contains("64"));
        assert!(msg.contains("32"));
    }

    #[test]
    fn test_io_error_file_not_found() {
        let error = IOError::FileNotFound {
            path: "/path/to/file.dat".to_string(),
        };
        let msg = error.to_string();
        assert!(msg.contains("file.dat"));
        assert!(msg.contains("not found"));
    }

    #[test]
    fn test_math_error_interpolation_out_of_bounds() {
        let error = MathError::InterpolationOutOfBounds {
            x: 100.0,
            xmin: 0.0,
            xmax: 50.0,
        };
        let msg = error.to_string();
        assert!(msg.contains("100"));
        assert!(msg.contains("50"));
    }

    #[test]
    fn test_error_is_clone() {
        let error = DataError::InsufficientData { min: 10, actual: 5 };
        let cloned = error.clone();
        assert_eq!(error.to_string(), cloned.to_string());
    }
}