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
//! Checked, provenance-preserving spectrum input (since 0.2.10).
use super::*;
use crate::fitting::{FeffFitTransform, FitSpace};
use crate::{BackgroundMethod, Spectrum};
/// Policy for the nominal lower R fit bound relative to the saved AUTOBK radius
/// (since 0.2.10). This is a rexafs input guard, not a leakage correction or a
/// physical convergence test. AUTOBK uses the low-R signal to determine background;
/// see the [algorithm documentation](https://xraypy.github.io/xraylarch/xafs_autobk.html).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum RmcRbkgPolicy {
/// Require rmin >= the resolved Rbkg (default). A larger margin must be chosen
/// explicitly in the fitting transform; no spectrum or bound is adjusted.
#[default]
ExcludeBelowRbkg,
/// Permit rmin < Rbkg for an explicit comparison. The nonblank reason is
/// retained in the processing snapshot and checkpoint.
AllowBelowRbkg {
/// Scientific reason for including the background-sensitive region.
reason: String,
},
}
/// Inputs for [`RmcDataset::from_spectrum`] (since 0.2.10). Use [`Self::new`] with
/// explicit absorbers, edge and the same [`FeffFitTransform`] used by ordinary
/// fitting. Plotting FFT settings are preserved as provenance, not adopted as fit
/// ranges. The constructor selects real + imaginary R fitting with one integer
/// k weight; unsupported fit spaces, fractional/multiple weights are errors.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RmcSpectrumOptions {
/// Distinct, zero-based absorbing atoms; element and bounds are checked once
/// a configuration is supplied to a session. Must not be empty.
pub absorbers: Vec<usize>,
/// Absorption edge; not inferred from the measured edge energy.
pub edge: Edge,
/// Explicit native fitting transform, with fitspace=R and one k weight 0..=3.
/// k is in Å⁻¹, R in Å. Use the measured support and a sample-specific R range.
pub transform: FeffFitTransform,
/// Inclusive measured k interval in Å⁻¹, before the fitting window. None copies
/// all measured points. Include the window tapers. A positive delta_e0 may
/// require excluding low k where the shifted theoretical k would be imaginary.
/// Selection preserves samples exactly: no interpolation or weighting here.
pub k_range: Option<[f64; 2]>,
/// Positive dimensionless scales aligned with the FULL spectrum k grid, before
/// selection. None uses unit numerical scales, not estimated measurement noise.
/// Absorption-space mu_stddev is never treated as EXAFS uncertainty.
pub sigma: Option<Vec<f64>>,
/// Fixed positive S₀²; default 1. Calibrate independently when appropriate.
pub s02: f64,
/// Fixed fitting energy shift in eV; default 0. This is not Spectrum::e0().
pub delta_e0: f64,
/// Normalize squared complex-R residuals by experimental power in the same
/// window (default true), as in the Cu₂O comparison. With nonuniform sigma,
/// both numerator and denominator use the supplied scales before transforming.
/// False retains the ordinary RMC mean squared objective. Neither is reduced χ².
pub normalize_by_experiment: bool,
/// Exclude R below Rbkg by default; exceptions require a recorded reason.
pub rbkg_policy: RmcRbkgPolicy,
}
impl RmcSpectrumOptions {
/// Select absorbers, edge and explicit fitting settings. Defaults copy all k,
/// use unit numerical sigma, S₀²=1, ΔE₀=0, experimental-power normalization,
/// and exclude R below Rbkg. Nothing is calculated or validated until
/// [`RmcDataset::from_spectrum`] is called. The defaults are not a material
/// calibration or an experimental uncertainty model.
pub fn new(absorbers: Vec<usize>, edge: Edge, transform: FeffFitTransform) -> Self {
Self {
absorbers,
edge,
transform,
k_range: None,
sigma: None,
s02: 1.,
delta_e0: 0.,
normalize_by_experiment: true,
rbkg_policy: RmcRbkgPolicy::default(),
}
}
}
/// Owned input snapshot captured before RMC (since 0.2.10). Includes the complete
/// serialized Spectrum state: baseline/current arrays, normalization, AUTOBK,
/// edge energy, and any Fourier settings/results. This preserves available
/// state, not an immutable raw acquisition or a complete processing history.
/// Private fields prevent accidental mutation through the normal Rust API.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RmcSpectrumSource {
spectrum: Spectrum,
selected_indices: Vec<usize>,
options: RmcSpectrumOptions,
rexafs_version: String,
}
impl RmcSpectrumSource {
/// Borrow the original captured spectrum without processing or cloning.
pub fn spectrum(&self) -> &Spectrum {
&self.spectrum
}
/// Original full-grid indices corresponding to the RMC experimental arrays.
pub fn selected_indices(&self) -> &[usize] {
&self.selected_indices
}
/// Options used at import. Later explicit edits to public dataset fields do
/// not rewrite this historical record; the dataset holds the current settings.
pub fn options(&self) -> &RmcSpectrumOptions {
&self.options
}
/// rexafs package version that captured the snapshot. Unreleased checkouts
/// can share a version; record the source revision separately for reproducibility.
pub fn rexafs_version(&self) -> &str {
&self.rexafs_version
}
}
fn processed(spectrum: &Spectrum) -> Result<(&[f64], &[f64], f64), RmcError> {
spectrum
.ensure_exafs_allowed()
.map_err(|e| RmcError::Invalid(e.to_string()))?;
let k = spectrum.k().ok_or_else(|| {
RmcError::Invalid(
"spectrum has no processed k; call calc_background() explicitly first".into(),
)
})?;
let chi = spectrum.chi().ok_or_else(|| {
RmcError::Invalid(
"spectrum has no processed chi; call calc_background() explicitly first".into(),
)
})?;
require(
k.len() >= 2
&& k.len() == chi.len()
&& k.iter().all(|x| x.is_finite() && *x >= 0.)
&& k.windows(2).all(|w| w[1] > w[0])
&& chi.iter().all(|x| x.is_finite()),
"spectrum needs finite, paired chi and strictly increasing nonnegative k",
)?;
let rbkg = match spectrum.background.as_ref() {
Some(BackgroundMethod::AUTOBK(background)) => background.rbkg,
_ => None,
};
let rbkg = rbkg.filter(|r| r.is_finite() && *r > 0.).ok_or_else(|| {
RmcError::Invalid("processed spectrum needs a resolved, positive AUTOBK Rbkg".into())
})?;
Ok((k, chi, rbkg))
}
fn check_rbkg(objective: &Objective, rbkg: f64, policy: &RmcRbkgPolicy) -> Result<(), RmcError> {
if let RmcRbkgPolicy::AllowBelowRbkg { reason } = policy {
require(
!reason.trim().is_empty(),
"Rbkg override needs a nonblank reason",
)?;
}
if let Objective::R(t) | Objective::Q(t) = objective {
require(
t.rmin >= rbkg || matches!(policy, RmcRbkgPolicy::AllowBelowRbkg { .. }),
format!("R fit minimum {} Å is below AUTOBK Rbkg {rbkg} Å; choose a higher rmin or explicitly use AllowBelowRbkg with a reason", t.rmin),
)?;
}
Ok(())
}
impl RmcDataset {
/// Capture a processed [`Spectrum`] as an owned complex-R dataset (since 0.2.10).
/// Reads authoritative [`Spectrum::k`] and [`Spectrum::chi`] getters, copies
/// selected unweighted samples and the complete processing state, and validates
/// the transform, shifted k support and Rbkg policy before scattering starts.
/// Uses the existing RMC/native fitting transform, not cached plotted χ(R).
///
/// No normalization, AUTOBK or FFT processing stage runs on the input. Call
/// `spectrum.calc_background()` explicitly first if needed. Legacy public
/// field edits require `invalidate_derived()` and reprocessing, as for the
/// ordinary Spectrum API; stale caches cannot be detected automatically.
/// Captured state is copied on problem/checkpoint cloning, not per MC move.
/// The caller's spectrum, including its settings and cached results, is unchanged.
///
/// The default objective is the squared real-plus-imaginary residual divided
/// by experimental power in the SAME R window. Missing/invalid processing,
/// empty selections, invalid scales, zero normalization power and unsupported
/// fitting settings return [`RmcError::Invalid`]. A fluorescence-corrected
/// XANES-only spectrum is rejected even if legacy EXAFS buffers are present.
/// Geometry-dependent absorber
/// checks occur at session creation. No files are written here.
///
/// ```no_run
/// use rexafs::{Spectrum, fitting::FeffFitTransform, rmc::*, structure::Edge};
/// fn dataset(spectrum: &Spectrum, absorbers: Vec<usize>, transform: FeffFitTransform)
/// -> Result<RmcDataset, RmcError>
/// {
/// let mut options = RmcSpectrumOptions::new(absorbers, Edge::K, transform);
/// options.k_range = Some([2.5, 12.]); // Sample-specific support, including tapers.
/// options.s02 = 0.98; // Example independently calibrated amplitude.
/// options.delta_e0 = 8.7; // Fitting shift in eV, not the edge energy.
/// RmcDataset::from_spectrum(spectrum, options)
/// }
/// ```
pub fn from_spectrum(
spectrum: &Spectrum,
options: RmcSpectrumOptions,
) -> Result<Self, RmcError> {
let (k, chi, rbkg) = processed(spectrum)?;
let weights = options.transform.effective_kweights();
require(
options.transform.fitspace == FitSpace::R
&& weights.len() == 1
&& weights[0].is_finite()
&& (0. ..=3.).contains(&weights[0])
&& weights[0].fract() == 0.,
"spectrum RMC input requires fitspace=R and one integer k weight in 0..=3",
)?;
let mut distinct = std::collections::HashSet::new();
require(
!options.absorbers.is_empty() && options.absorbers.iter().all(|a| distinct.insert(a)),
"spectrum RMC input needs nonempty, distinct absorber indices",
)?;
require(
options.s02.is_finite() && options.s02 > 0.,
"S02 must be finite and positive",
)?;
require(
options.delta_e0.is_finite(),
"fitting delta_e0 must be finite",
)?;
if let Some(sigma) = &options.sigma {
require(
sigma.len() == k.len() && sigma.iter().all(|s| s.is_finite() && *s > 0.),
"sigma must contain one finite positive scale per full-spectrum k point",
)?;
}
let [lo, hi] = options.k_range.unwrap_or([k[0], k[k.len() - 1]]);
require(
lo.is_finite() && hi.is_finite() && lo < hi && lo >= k[0] && hi <= k[k.len() - 1],
"k_range must be increasing and lie inside the measured k support",
)?;
let indices: Vec<_> = k
.iter()
.enumerate()
.filter_map(|(i, &x)| (x >= lo && x <= hi).then_some(i))
.collect();
require(
indices.len() >= 2,
"k_range must select at least two measured samples",
)?;
let exafs = ExafsDataset {
name: spectrum
.name
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("spectrum")
.to_owned(),
absorbers: options.absorbers.clone(),
edge: options.edge,
k: indices.iter().map(|&i| k[i]).collect(),
chi: indices.iter().map(|&i| chi[i]).collect(),
sigma: indices
.iter()
.map(|&i| options.sigma.as_ref().map_or(1., |s| s[i]))
.collect(),
weight: 1.,
kweight: weights[0] as u8,
s02: options.s02,
delta_e0: options.delta_e0,
};
let objective = Objective::R(options.transform.clone());
check_rbkg(&objective, rbkg, &options.rbkg_policy)?;
super::engine::shifted_grid(&exafs).map_err(|e| {
RmcError::Invalid(format!(
"{e}; select k_range above the fitting energy-shift threshold"
))
})?;
let mut dataset: Self = exafs.into();
dataset.objective = objective;
// Validate Fourier support even when experimental normalization is disabled.
dataset
.objective
.score(&dataset.exafs, &dataset.exafs.chi)?;
if options.normalize_by_experiment {
dataset.normalize_experimental_power()?;
}
dataset.source = Some(Box::new(RmcSpectrumSource {
spectrum: spectrum.clone(),
selected_indices: indices,
options,
rexafs_version: env!("CARGO_PKG_VERSION").into(),
}));
Ok(dataset)
}
/// Set dataset weight so a zero model scores 1 in its current objective
/// (since 0.2.10). Applies noise and k weights once to both data and residual.
/// Call again after changing the transform, kweight or sigma if this scaling
/// is wanted; later field edits never silently renormalize the objective.
/// Replaces the prior dataset weight. No scattering or preprocessing runs.
/// Invalid/zero power returns an error and leaves the weight unchanged.
pub fn normalize_experimental_power(&mut self) -> Result<(), RmcError> {
let mut data = self.exafs.clone();
data.weight = 1.;
let power = self.objective.score(&data, &vec![0.; data.k.len()])?;
let weight = 1. / power;
require(
power > 0. && weight.is_finite() && weight > 0.,
"experimental objective power must be finite and positive",
)?;
self.exafs.weight = weight;
Ok(())
}
pub(super) fn validate_spectrum_source(&self) -> Result<(), RmcError> {
let Some(source) = &self.source else {
return Ok(());
};
let (k, chi, rbkg) = processed(&source.spectrum)?;
let indices = &source.selected_indices;
require(indices.len() == self.exafs.k.len() && indices.len() == self.exafs.chi.len()
&& indices.windows(2).all(|w| w[1] > w[0])
&& indices.iter().enumerate().all(|(j, &i)| i < k.len() && k[i] == self.exafs.k[j] && chi[i] == self.exafs.chi[j]),
"RMC experimental arrays no longer match their captured spectrum; rebuild with from_spectrum")?;
check_rbkg(&self.objective, rbkg, &source.options.rbkg_policy)
}
}