Skip to main content

phasesmith_workflows/
tof_lebail.rs

1//! Native fixed-instrument time-of-flight Le Bail extraction.
2//!
3//! TOF coordinates remain in microseconds and reflections remain parameterized
4//! by d-spacing. Profile values, intensity/d-spacing derivatives, and all 15
5//! shared instrument derivatives are produced by the fused core accumulation.
6
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9
10use nalgebra::{DMatrix, DVector};
11use phasesmith_core::{
12    Accumulation, GridView, TofError, TofInstrument, TofProfileParameters,
13    accumulate_tof_batch_with_context,
14};
15use phasesmith_execution::{ExecutionPolicy, ExecutionPolicyError};
16use phasesmith_model::{DomainError, RecordId, TofPatternRecord};
17
18use crate::{
19    DiagnosticValue, RefinementEventKind, RefinementLimits, RefinementRuntime, ResidualError,
20    ResidualEvaluation, ResidualOptions, RuntimeError, TerminationReason, evaluate_tof_residuals,
21};
22
23/// One fixed-topology phase in a TOF Le Bail extraction.
24#[derive(Clone, Debug, PartialEq)]
25pub struct TofLeBailPhase {
26    phase_id: RecordId,
27    name: String,
28    reflection_ids: Vec<String>,
29    hkl: Vec<[i32; 3]>,
30    d_spacing_angstrom: Vec<f64>,
31    integrated_intensity: Vec<f64>,
32    scale: f64,
33}
34
35impl TofLeBailPhase {
36    /// Validate and own one fixed TOF reflection batch.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`TofLeBailError`] for empty, mismatched, duplicated, or
41    /// nonphysical phase data.
42    pub fn new(
43        phase_id: RecordId,
44        name: impl Into<String>,
45        reflection_ids: Vec<String>,
46        hkl: Vec<[i32; 3]>,
47        d_spacing_angstrom: Vec<f64>,
48        integrated_intensity: Vec<f64>,
49        scale: f64,
50    ) -> Result<Self, TofLeBailError> {
51        let result = Self {
52            phase_id,
53            name: name.into(),
54            reflection_ids,
55            hkl,
56            d_spacing_angstrom,
57            integrated_intensity,
58            scale,
59        };
60        result.validate()?;
61        Ok(result)
62    }
63
64    /// Revalidate caller-mutated or adapter-decoded phase state.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`TofLeBailError`] when a phase invariant is violated.
69    pub fn validate(&self) -> Result<(), TofLeBailError> {
70        let count = self.reflection_ids.len();
71        if self.name.trim().is_empty() || count == 0 {
72            return Err(TofLeBailError::InvalidPhase(
73                "TOF Le Bail phases require a name and at least one reflection",
74            ));
75        }
76        if self.hkl.len() != count
77            || self.d_spacing_angstrom.len() != count
78            || self.integrated_intensity.len() != count
79        {
80            return Err(TofLeBailError::InvalidPhase(
81                "TOF phase reflection arrays must have equal length",
82            ));
83        }
84        if self.reflection_ids.iter().any(String::is_empty)
85            || self
86                .reflection_ids
87                .iter()
88                .collect::<std::collections::BTreeSet<_>>()
89                .len()
90                != count
91        {
92            return Err(TofLeBailError::InvalidPhase(
93                "TOF reflection IDs must be non-empty and unique within a phase",
94            ));
95        }
96        if self
97            .d_spacing_angstrom
98            .iter()
99            .any(|value| !value.is_finite() || *value <= 0.0)
100            || self
101                .integrated_intensity
102                .iter()
103                .any(|value| !value.is_finite() || *value < 0.0)
104            || !self.scale.is_finite()
105            || self.scale <= 0.0
106        {
107            return Err(TofLeBailError::InvalidPhase(
108                "TOF d-spacings and scale must be positive; intensities must be nonnegative",
109            ));
110        }
111        Ok(())
112    }
113
114    /// Stable phase identifier.
115    #[must_use]
116    pub const fn phase_id(&self) -> &RecordId {
117        &self.phase_id
118    }
119
120    /// Human-readable phase name.
121    #[must_use]
122    pub fn name(&self) -> &str {
123        &self.name
124    }
125
126    /// Stable reflection identifiers.
127    #[must_use]
128    pub fn reflection_ids(&self) -> &[String] {
129        &self.reflection_ids
130    }
131
132    /// Miller indices in reflection order.
133    #[must_use]
134    pub fn hkl(&self) -> &[[i32; 3]] {
135        &self.hkl
136    }
137
138    /// Durable local reflection coordinates in ångströms.
139    #[must_use]
140    pub fn d_spacing_angstrom(&self) -> &[f64] {
141        &self.d_spacing_angstrom
142    }
143
144    /// Current nonnegative integrated intensities.
145    #[must_use]
146    pub fn integrated_intensity(&self) -> &[f64] {
147        &self.integrated_intensity
148    }
149
150    /// Phase scale applied before accumulation.
151    #[must_use]
152    pub const fn scale(&self) -> f64 {
153        self.scale
154    }
155
156    fn with_intensities(&self, values: &[f64]) -> Result<Self, TofLeBailError> {
157        if values.len() != self.integrated_intensity.len() {
158            return Err(TofLeBailError::IntensityLengthMismatch);
159        }
160        let mut result = self.clone();
161        result.integrated_intensity.copy_from_slice(values);
162        result.validate()?;
163        Ok(result)
164    }
165
166    pub(crate) fn with_d_spacings(&self, values: &[f64]) -> Result<Self, TofLeBailError> {
167        if values.len() != self.d_spacing_angstrom.len() {
168            return Err(TofLeBailError::DSpacingLengthMismatch);
169        }
170        let mut result = self.clone();
171        result.d_spacing_angstrom.copy_from_slice(values);
172        result.validate()?;
173        Ok(result)
174    }
175}
176
177/// Refinable Chebyshev series on one explicit TOF interval.
178///
179/// For `u = 2 (tof - lower) / (upper - lower) - 1`, the background is
180/// `sum_k coefficient[k] T_k(u)`. Coefficient derivatives are the corresponding
181/// Chebyshev basis values and are independent of the coefficients.
182#[derive(Clone, Debug, PartialEq)]
183pub struct TofChebyshevBackground {
184    background_id: RecordId,
185    coefficients: Vec<f64>,
186    domain_us: [f64; 2],
187}
188
189impl TofChebyshevBackground {
190    /// Construct a non-empty finite Chebyshev series on an increasing interval.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`TofLeBailError`] for invalid identity, coefficients, or domain.
195    pub fn new(
196        background_id: RecordId,
197        coefficients: Vec<f64>,
198        domain_us: [f64; 2],
199    ) -> Result<Self, TofLeBailError> {
200        let result = Self {
201            background_id,
202            coefficients,
203            domain_us,
204        };
205        result.validate()?;
206        Ok(result)
207    }
208
209    /// Revalidate the model and its explicit microsecond domain.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`TofLeBailError`] for empty/non-finite coefficients or an invalid domain.
214    pub fn validate(&self) -> Result<(), TofLeBailError> {
215        if self.coefficients.is_empty()
216            || self.coefficients.iter().any(|value| !value.is_finite())
217            || self.domain_us.iter().any(|value| !value.is_finite())
218            || self.domain_us[0] >= self.domain_us[1]
219        {
220            return Err(TofLeBailError::InvalidBackground);
221        }
222        Ok(())
223    }
224
225    /// Stable background identifier.
226    #[must_use]
227    pub const fn background_id(&self) -> &RecordId {
228        &self.background_id
229    }
230
231    /// Current coefficients in increasing Chebyshev order.
232    #[must_use]
233    pub fn coefficients(&self) -> &[f64] {
234        &self.coefficients
235    }
236
237    /// Explicit closed TOF domain in microseconds.
238    #[must_use]
239    pub const fn domain_us(&self) -> [f64; 2] {
240        self.domain_us
241    }
242
243    /// Evaluate the series on a sorted finite microsecond grid.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`TofLeBailError`] if the grid is invalid or outside the domain.
248    pub fn calculate(&self, tof_us: &[f64]) -> Result<Vec<f64>, TofLeBailError> {
249        let basis = self.basis(tof_us)?;
250        self.calculate_from_basis(&basis)
251    }
252
253    pub(crate) fn calculate_from_basis(
254        &self,
255        basis: &TofChebyshevBasis,
256    ) -> Result<Vec<f64>, TofLeBailError> {
257        let expected = basis
258            .rows
259            .checked_mul(basis.columns)
260            .ok_or(TofLeBailError::AllocationOverflow)?;
261        if basis.columns != self.coefficients.len() || basis.values.len() != expected {
262            return Err(TofLeBailError::BackgroundBasisShape);
263        }
264        Ok(basis
265            .values
266            .chunks_exact(basis.columns)
267            .map(|row| {
268                row.iter()
269                    .zip(&self.coefficients)
270                    .map(|(basis, coefficient)| basis * coefficient)
271                    .sum()
272            })
273            .collect())
274    }
275
276    pub(crate) fn basis(&self, tof_us: &[f64]) -> Result<TofChebyshevBasis, TofLeBailError> {
277        self.validate()?;
278        if tof_us.is_empty()
279            || tof_us.iter().any(|value| !value.is_finite())
280            || tof_us.windows(2).any(|pair| pair[0] >= pair[1])
281        {
282            return Err(TofLeBailError::InvalidBackgroundGrid);
283        }
284        let lower = self.domain_us[0];
285        let upper = self.domain_us[1];
286        let tolerance = 64.0 * f64::EPSILON * lower.abs().max(upper.abs()).max(1.0);
287        if tof_us
288            .iter()
289            .any(|value| *value < lower - tolerance || *value > upper + tolerance)
290        {
291            return Err(TofLeBailError::BackgroundGridOutsideDomain);
292        }
293        let columns = self.coefficients.len();
294        let count = tof_us
295            .len()
296            .checked_mul(columns)
297            .ok_or(TofLeBailError::AllocationOverflow)?;
298        let mut values = vec![0.0; count];
299        for (tof, row) in tof_us.iter().zip(values.chunks_exact_mut(columns)) {
300            let normalized = 2.0 * (tof - lower) / (upper - lower) - 1.0;
301            row[0] = 1.0;
302            if columns > 1 {
303                row[1] = normalized;
304            }
305            for order in 2..columns {
306                row[order] = 2.0 * normalized * row[order - 1] - row[order - 2];
307            }
308        }
309        Ok(TofChebyshevBasis {
310            rows: tof_us.len(),
311            columns,
312            values,
313        })
314    }
315
316    pub(crate) fn with_coefficients(&self, coefficients: Vec<f64>) -> Result<Self, TofLeBailError> {
317        if coefficients.len() != self.coefficients.len() {
318            return Err(TofLeBailError::BackgroundCoefficientLengthMismatch);
319        }
320        Self::new(self.background_id.clone(), coefficients, self.domain_us)
321    }
322}
323
324/// Sample-major analytical Chebyshev coefficient derivatives.
325#[derive(Clone, Debug, PartialEq)]
326pub struct TofChebyshevBasis {
327    /// Number of TOF samples.
328    pub rows: usize,
329    /// Number of coefficients.
330    pub columns: usize,
331    /// Sample-major basis values.
332    pub values: Vec<f64>,
333}
334
335/// Observations, fixed TOF instrument, and ordered phases.
336#[derive(Clone, Debug, PartialEq)]
337pub struct TofLeBailInput {
338    /// Explicit microsecond-domain observed pattern.
339    pub pattern: TofPatternRecord,
340    /// Fixed 15-coefficient TOF profile model.
341    pub instrument: TofInstrument,
342    /// Ordered non-empty phase list.
343    pub phases: Vec<TofLeBailPhase>,
344    /// Optional refinable Chebyshev background; otherwise the pattern background is fixed.
345    pub background: Option<TofChebyshevBackground>,
346}
347
348impl TofLeBailInput {
349    /// Validate a complete TOF extraction request.
350    ///
351    /// # Errors
352    ///
353    /// Returns [`TofLeBailError`] for invalid pattern, instrument, phase, or
354    /// identity state.
355    pub fn new(
356        pattern: TofPatternRecord,
357        instrument: TofInstrument,
358        phases: Vec<TofLeBailPhase>,
359    ) -> Result<Self, TofLeBailError> {
360        let result = Self {
361            pattern,
362            instrument,
363            phases,
364            background: None,
365        };
366        result.validate()?;
367        Ok(result)
368    }
369
370    /// Attach a refinable Chebyshev residual on top of the fixed pattern background.
371    ///
372    /// # Errors
373    ///
374    /// Returns [`TofLeBailError`] if the model or its grid/domain relationship is invalid.
375    pub fn with_refinable_background(
376        mut self,
377        background: TofChebyshevBackground,
378    ) -> Result<Self, TofLeBailError> {
379        self.background = Some(background);
380        self.validate()?;
381        Ok(self)
382    }
383
384    /// Revalidate all trust-boundary state.
385    ///
386    /// # Errors
387    ///
388    /// Returns [`TofLeBailError`] when any request invariant is violated.
389    pub fn validate(&self) -> Result<(), TofLeBailError> {
390        self.pattern.validate().map_err(TofLeBailError::Pattern)?;
391        if self.pattern.observed_y.is_none() {
392            return Err(TofLeBailError::MissingObservations);
393        }
394        self.instrument
395            .validate()
396            .map_err(TofLeBailError::Profile)?;
397        if self.phases.is_empty() {
398            return Err(TofLeBailError::InvalidPhase(
399                "TOF Le Bail input requires at least one phase",
400            ));
401        }
402        let mut phase_ids = std::collections::BTreeSet::new();
403        for phase in &self.phases {
404            phase.validate()?;
405            if !phase_ids.insert(phase.phase_id()) {
406                return Err(TofLeBailError::InvalidPhase(
407                    "TOF Le Bail phase IDs must be unique",
408                ));
409            }
410            for d_spacing in phase.d_spacing_angstrom() {
411                TofProfileParameters::from_instrument(*d_spacing, self.instrument)
412                    .map_err(TofLeBailError::Profile)?;
413            }
414        }
415        if let Some(background) = &self.background {
416            background.validate()?;
417            background.basis(&self.pattern.tof_us)?;
418        }
419        Ok(())
420    }
421}
422
423/// Deterministic fixed-instrument TOF extraction controls.
424#[derive(Clone, Debug, PartialEq)]
425pub struct TofLeBailOptions {
426    /// Number of nonnegative redistribution cycles.
427    pub cycles: usize,
428    /// Multiplicative update damping in `(0, 1]`.
429    pub redistribution_damping: f64,
430    /// Positive floor used only to initialize an all-zero reflection list.
431    pub initial_intensity_floor: f64,
432    /// Minimum calculated profile accepted in an observed/calculated ratio.
433    pub minimum_calculated: f64,
434    /// Symmetric TCH support radius in total-FWHM units.
435    pub support_fwhm: f64,
436    /// Exponential truncation exponent; each tail ends at `exp(-tail_log)`.
437    pub tail_log: f64,
438    /// Use supplied one-sigma uncertainties in background fitting and metrics.
439    pub use_uncertainty: bool,
440    /// Use supplied one-sigma uncertainties in multiplicative redistribution.
441    ///
442    /// This defaults to `use_uncertainty` but can be disabled independently
443    /// for GSAS-compatible unweighted Le Bail partitioning while retaining
444    /// uncertainty-weighted background fitting and residual metrics.
445    pub redistribution_use_uncertainty: bool,
446    /// Bounded execution policy passed to the fused core kernel.
447    pub execution: ExecutionPolicy,
448}
449
450impl TofLeBailOptions {
451    /// Construct validated extraction controls.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`TofLeBailError`] for invalid numerical controls.
456    #[allow(clippy::too_many_arguments)]
457    pub fn new(
458        cycles: usize,
459        redistribution_damping: f64,
460        initial_intensity_floor: f64,
461        minimum_calculated: f64,
462        support_fwhm: f64,
463        tail_log: f64,
464        use_uncertainty: bool,
465        execution: ExecutionPolicy,
466    ) -> Result<Self, TofLeBailError> {
467        let result = Self {
468            cycles,
469            redistribution_damping,
470            initial_intensity_floor,
471            minimum_calculated,
472            support_fwhm,
473            tail_log,
474            use_uncertainty,
475            redistribution_use_uncertainty: use_uncertainty,
476            execution,
477        };
478        result.validate()?;
479        Ok(result)
480    }
481
482    /// Select uncertainty weighting independently for intensity redistribution.
483    #[must_use]
484    pub fn with_redistribution_uncertainty(mut self, enabled: bool) -> Self {
485        self.redistribution_use_uncertainty = enabled;
486        self
487    }
488
489    /// Construct practical deterministic defaults.
490    ///
491    /// # Errors
492    ///
493    /// Returns [`TofLeBailError`] if the defaults cannot be validated.
494    pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, TofLeBailError> {
495        Self::new(50, 1.0, 1.0e-12, 1.0e-15, 20.0, 20.0, true, execution)
496    }
497
498    /// Revalidate numerical controls.
499    ///
500    /// # Errors
501    ///
502    /// Returns [`TofLeBailError`] for zero, non-finite, or out-of-range values.
503    pub fn validate(&self) -> Result<(), TofLeBailError> {
504        if self.cycles == 0
505            || !self.redistribution_damping.is_finite()
506            || self.redistribution_damping <= 0.0
507            || self.redistribution_damping > 1.0
508            || !self.initial_intensity_floor.is_finite()
509            || self.initial_intensity_floor <= 0.0
510            || !self.minimum_calculated.is_finite()
511            || self.minimum_calculated <= 0.0
512            || !self.support_fwhm.is_finite()
513            || self.support_fwhm <= 0.0
514            || !self.tail_log.is_finite()
515            || self.tail_log <= 0.0
516        {
517            return Err(TofLeBailError::InvalidOptions);
518        }
519        Ok(())
520    }
521}
522
523/// Display-ready TOF profile plus the fused derivative product.
524#[derive(Clone, Debug, PartialEq)]
525pub struct TofLeBailCalculation {
526    /// Profile plus fixed or refinable background.
527    pub y: Vec<f64>,
528    /// Sum of all reflection profiles.
529    pub profile_y: Vec<f64>,
530    /// Evaluated fixed or refinable background.
531    pub background_y: Vec<f64>,
532    /// Sample-major analytical coefficient derivatives for a refinable background.
533    pub background_basis: Option<TofChebyshevBasis>,
534    /// Fused intensity/d-spacing and 15-row instrument derivatives.
535    pub accumulation: Accumulation,
536    /// `(phase_id, reflection_id)` in local derivative order.
537    pub reflection_keys: Vec<(String, String)>,
538    /// Prefix sum of phase reflection counts.
539    pub phase_offsets: Vec<usize>,
540}
541
542/// One accepted extraction cycle.
543#[derive(Clone, Debug, PartialEq)]
544pub struct TofLeBailIterationRecord {
545    /// One-based cycle index.
546    pub iteration: usize,
547    /// Residual metrics after redistribution.
548    pub metrics: ResidualEvaluation,
549    /// Largest floored relative intensity change.
550    pub maximum_relative_intensity_change: f64,
551    /// Largest absolute Chebyshev coefficient change, or zero for a fixed background.
552    pub maximum_absolute_background_change: f64,
553}
554
555/// Stable final reflection intensity.
556#[derive(Clone, Debug, PartialEq)]
557pub struct TofReflectionIntensity {
558    /// Stable phase ID.
559    pub phase_id: String,
560    /// Stable reflection ID.
561    pub reflection_id: String,
562    /// Nonnegative extracted integrated intensity.
563    pub integrated_intensity: f64,
564}
565
566/// Complete fixed-instrument TOF extraction result.
567#[derive(Clone, Debug, PartialEq)]
568pub struct TofLeBailResult {
569    /// Final calculated pattern and analytical derivatives.
570    pub calculation: TofLeBailCalculation,
571    /// Final residual metrics.
572    pub metrics: ResidualEvaluation,
573    /// Final phase state.
574    pub phases: Vec<TofLeBailPhase>,
575    /// Final refinable background state, if requested.
576    pub background: Option<TofChebyshevBackground>,
577    /// Flattened stable reflection intensities.
578    pub intensities: Vec<TofReflectionIntensity>,
579    /// Complete deterministic cycle history.
580    pub history: Vec<TofLeBailIterationRecord>,
581    /// Stable bounded-runtime termination category.
582    pub termination_reason: TerminationReason,
583    /// Complete last accepted state for exact continuation.
584    pub checkpoint: TofLeBailCheckpoint,
585}
586
587/// Complete immutable continuation state for fixed-instrument TOF extraction.
588#[derive(Clone, Debug, PartialEq)]
589pub struct TofLeBailCheckpoint {
590    /// Number of accepted redistribution cycles.
591    pub completed_iterations: usize,
592    /// Current phase records and nonnegative integrated intensities.
593    pub phases: Vec<TofLeBailPhase>,
594    /// Current refinable residual background, if present.
595    pub background: Option<TofChebyshevBackground>,
596    /// Complete accepted deterministic history.
597    pub history: Vec<TofLeBailIterationRecord>,
598}
599
600impl TofLeBailCheckpoint {
601    /// Revalidate a continuation against its immutable request topology.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`TofLeBailError::InvalidCheckpoint`] when iteration counters,
606    /// phase identities/topology, or background identity/domain disagree.
607    pub fn validate_for(
608        &self,
609        input: &TofLeBailInput,
610        options: &TofLeBailOptions,
611    ) -> Result<(), TofLeBailError> {
612        if self.completed_iterations != self.history.len()
613            || self.completed_iterations > options.cycles
614        {
615            return Err(TofLeBailError::InvalidCheckpoint(
616                "checkpoint iteration count must equal history length and fit the cycle budget",
617            ));
618        }
619        if self.phases.len() != input.phases.len() {
620            return Err(TofLeBailError::InvalidCheckpoint(
621                "checkpoint phase count differs from the request",
622            ));
623        }
624        for (saved, original) in self.phases.iter().zip(&input.phases) {
625            saved.validate()?;
626            if saved.phase_id != original.phase_id
627                || saved.name != original.name
628                || saved.reflection_ids != original.reflection_ids
629                || saved.hkl != original.hkl
630                || saved.d_spacing_angstrom != original.d_spacing_angstrom
631                || saved.scale.to_bits() != original.scale.to_bits()
632            {
633                return Err(TofLeBailError::InvalidCheckpoint(
634                    "checkpoint phase topology differs from the request",
635                ));
636            }
637        }
638        match (&self.background, &input.background) {
639            (None, None) => {}
640            (Some(saved), Some(original)) => {
641                saved.validate()?;
642                if saved.background_id != original.background_id
643                    || saved
644                        .domain_us
645                        .iter()
646                        .zip(original.domain_us)
647                        .any(|(saved, original)| saved.to_bits() != original.to_bits())
648                    || saved.coefficients.len() != original.coefficients.len()
649                {
650                    return Err(TofLeBailError::InvalidCheckpoint(
651                        "checkpoint background contract differs from the request",
652                    ));
653                }
654            }
655            _ => {
656                return Err(TofLeBailError::InvalidCheckpoint(
657                    "checkpoint background presence differs from the request",
658                ));
659            }
660        }
661        if self
662            .history
663            .iter()
664            .enumerate()
665            .any(|(index, record)| record.iteration != index + 1)
666        {
667            return Err(TofLeBailError::InvalidCheckpoint(
668                "checkpoint history must be contiguous and one-based",
669            ));
670        }
671        Ok(())
672    }
673}
674
675/// Calculate one TOF pattern and all direct profile derivatives in one pass.
676///
677/// # Errors
678///
679/// Returns [`TofLeBailError`] for invalid input, profile evaluation, or
680/// allocation failure.
681pub fn calculate_tof_lebail_pattern(
682    input: &TofLeBailInput,
683    options: &TofLeBailOptions,
684) -> Result<TofLeBailCalculation, TofLeBailError> {
685    input.validate()?;
686    options.validate()?;
687    let reflection_count = input
688        .phases
689        .iter()
690        .try_fold(0_usize, |count, phase| {
691            count.checked_add(phase.reflection_ids.len())
692        })
693        .ok_or(TofLeBailError::AllocationOverflow)?;
694    let mut d_spacing = Vec::with_capacity(reflection_count);
695    let mut intensity = Vec::with_capacity(reflection_count);
696    let mut reflection_keys = Vec::with_capacity(reflection_count);
697    let mut phase_offsets = Vec::with_capacity(input.phases.len() + 1);
698    phase_offsets.push(0);
699    for phase in &input.phases {
700        d_spacing.extend_from_slice(&phase.d_spacing_angstrom);
701        intensity.extend(
702            phase
703                .integrated_intensity
704                .iter()
705                .map(|value| phase.scale * value),
706        );
707        reflection_keys.extend(
708            phase.reflection_ids.iter().map(|reflection_id| {
709                (phase.phase_id.as_str().to_owned(), reflection_id.to_owned())
710            }),
711        );
712        phase_offsets.push(d_spacing.len());
713    }
714    let accumulation = accumulate_tof_batch_with_context(
715        GridView::new(&input.pattern.tof_us).map_err(TofError::from)?,
716        &d_spacing,
717        &intensity,
718        input.instrument,
719        options.support_fwhm,
720        options.tail_log,
721        options.execution.context(),
722    )?;
723    let profile_y = accumulation.y.clone();
724    let (mut background_y, background_basis) = if let Some(background) = &input.background {
725        let basis = background.basis(&input.pattern.tof_us)?;
726        let values = background.calculate_from_basis(&basis)?;
727        (values, Some(basis))
728    } else {
729        (vec![0.0; input.pattern.sample_count()], None)
730    };
731    for (residual, fixed) in background_y.iter_mut().zip(&input.pattern.background_y) {
732        *residual += fixed;
733    }
734    let y = profile_y
735        .iter()
736        .zip(&background_y)
737        .map(|(profile, background)| profile + background)
738        .collect();
739    Ok(TofLeBailCalculation {
740        y,
741        profile_y,
742        background_y,
743        background_basis,
744        accumulation,
745        reflection_keys,
746        phase_offsets,
747    })
748}
749
750/// Run deterministic nonnegative TOF Le Bail redistribution.
751///
752/// # Errors
753///
754/// Returns [`TofLeBailError`] for invalid state, profile evaluation, or
755/// residual calculation failure.
756pub fn refine_tof_lebail(
757    input: &TofLeBailInput,
758    options: &TofLeBailOptions,
759) -> Result<TofLeBailResult, TofLeBailError> {
760    input.validate()?;
761    options.validate()?;
762    let max_evaluations = options
763        .cycles
764        .checked_mul(3)
765        .ok_or(TofLeBailError::AllocationOverflow)?;
766    let limits = RefinementLimits::new(options.cycles, max_evaluations, None, 1)?;
767    let mut runtime = RefinementRuntime::new(limits, None)?;
768    refine_tof_lebail_with_runtime(input, options, None, &mut runtime)
769}
770
771/// Run TOF Le Bail extraction with host-owned cancellation, events, and checkpoints.
772///
773/// The runtime must be fresh. When `checkpoint` is supplied, its accepted
774/// iteration count is restored before additional work begins. Cancellation and
775/// budget exhaustion return the last fully accepted state as a normal result.
776///
777/// # Errors
778///
779/// Returns [`TofLeBailError`] for invalid request/checkpoint state, numerical
780/// failures, or non-normal runtime failures.
781#[allow(clippy::too_many_lines)]
782pub fn refine_tof_lebail_with_runtime(
783    input: &TofLeBailInput,
784    options: &TofLeBailOptions,
785    checkpoint: Option<&TofLeBailCheckpoint>,
786    runtime: &mut RefinementRuntime<TofLeBailCheckpoint>,
787) -> Result<TofLeBailResult, TofLeBailError> {
788    input.validate()?;
789    options.validate()?;
790    let restored = restore_tof_state(input, options, checkpoint)?;
791    let mut phases = restored.phases;
792    let mut background = restored.background;
793    let mut history = restored.history;
794    let first_iteration = restored.first_iteration;
795    if let Some(checkpoint) = checkpoint {
796        runtime.resume_accepted(checkpoint.completed_iterations)?;
797    }
798    runtime.emit(
799        RefinementEventKind::Start,
800        "tof_lebail",
801        "TOF Le Bail extraction started",
802        Vec::new(),
803    )?;
804    let mut calculation = None;
805    let mut termination = TerminationReason::MaxIterations;
806    for iteration in first_iteration..=options.cycles {
807        if let Err(error) = runtime.begin_iteration(iteration) {
808            termination = normal_tof_stop(error)?;
809            break;
810        }
811        if let Err(error) = runtime.begin_evaluation() {
812            termination = normal_tof_stop(error)?;
813            break;
814        }
815        let current_input = state_input(input, phases.clone(), background.clone())?;
816        let current_calculation = calculate_tof_lebail_pattern(&current_input, options)?;
817        let current = flatten_intensities(&phases);
818        let updated = redistribute(&input.pattern, &current_calculation, &current, options)?;
819        let maximum_relative_intensity_change = updated
820            .iter()
821            .zip(&current)
822            .map(|(updated, current)| {
823                (updated - current).abs() / current.abs().max(options.initial_intensity_floor)
824            })
825            .fold(0.0_f64, f64::max);
826        let candidate_phases = install_intensities(&phases, &updated)?;
827        if let Err(error) = runtime.begin_evaluation() {
828            termination = normal_tof_stop(error)?;
829            break;
830        }
831        let intensity_input = state_input(input, candidate_phases.clone(), background.clone())?;
832        let intensity_calculation = calculate_tof_lebail_pattern(&intensity_input, options)?;
833        let candidate_background = refine_background(
834            &input.pattern,
835            &intensity_calculation.profile_y,
836            background.as_ref(),
837            options,
838        )?;
839        let maximum_absolute_background_change =
840            maximum_background_change(background.as_ref(), candidate_background.as_ref());
841        if let Err(error) = runtime.begin_evaluation() {
842            termination = normal_tof_stop(error)?;
843            break;
844        }
845        let accepted_input = state_input(
846            input,
847            candidate_phases.clone(),
848            candidate_background.clone(),
849        )?;
850        let accepted = calculate_tof_lebail_pattern(&accepted_input, options)?;
851        let background_parameter_count = candidate_background
852            .as_ref()
853            .map_or(0, |background| background.coefficients.len());
854        let metrics = evaluate_tof_residuals(
855            &input.pattern,
856            &accepted.y,
857            ResidualOptions {
858                use_uncertainty: options.use_uncertainty,
859                parameter_count: updated.len() + background_parameter_count,
860            },
861        )?;
862        phases = candidate_phases;
863        background = candidate_background;
864        history.push(TofLeBailIterationRecord {
865            iteration,
866            metrics: metrics.clone(),
867            maximum_relative_intensity_change,
868            maximum_absolute_background_change,
869        });
870        calculation = Some(accepted);
871        let accepted_checkpoint = TofLeBailCheckpoint {
872            completed_iterations: history.len(),
873            phases: phases.clone(),
874            background: background.clone(),
875            history: history.clone(),
876        };
877        runtime.accept_step(Some(&accepted_checkpoint))?;
878        runtime.emit(
879            RefinementEventKind::Iteration,
880            "tof_lebail_iteration",
881            "TOF Le Bail cycle accepted",
882            vec![
883                ("rwp".to_owned(), DiagnosticValue::Float(metrics.rwp)),
884                (
885                    "maximum_relative_intensity_change".to_owned(),
886                    DiagnosticValue::Float(maximum_relative_intensity_change),
887                ),
888                (
889                    "maximum_absolute_background_change".to_owned(),
890                    DiagnosticValue::Float(maximum_absolute_background_change),
891                ),
892            ],
893        )?;
894    }
895    let calculation = if let Some(calculation) = calculation {
896        calculation
897    } else {
898        let final_input = state_input(input, phases.clone(), background.clone())?;
899        calculate_tof_lebail_pattern(&final_input, options)?
900    };
901    let background_parameter_count = background
902        .as_ref()
903        .map_or(0, |background| background.coefficients.len());
904    let metrics = evaluate_tof_residuals(
905        &input.pattern,
906        &calculation.y,
907        ResidualOptions {
908            use_uncertainty: options.use_uncertainty,
909            parameter_count: flatten_intensities(&phases).len() + background_parameter_count,
910        },
911    )?;
912    let final_checkpoint = TofLeBailCheckpoint {
913        completed_iterations: history.len(),
914        phases: phases.clone(),
915        background: background.clone(),
916        history: history.clone(),
917    };
918    final_checkpoint.validate_for(input, options)?;
919    let intensities = phases
920        .iter()
921        .flat_map(|phase| {
922            phase
923                .reflection_ids
924                .iter()
925                .zip(&phase.integrated_intensity)
926                .map(
927                    |(reflection_id, integrated_intensity)| TofReflectionIntensity {
928                        phase_id: phase.phase_id.as_str().to_owned(),
929                        reflection_id: reflection_id.clone(),
930                        integrated_intensity: *integrated_intensity,
931                    },
932                )
933        })
934        .collect();
935    runtime.emit(
936        RefinementEventKind::Termination,
937        "tof_lebail",
938        "TOF Le Bail extraction terminated",
939        vec![(
940            "termination_reason".to_owned(),
941            DiagnosticValue::String(termination.as_str().to_owned()),
942        )],
943    )?;
944    Ok(TofLeBailResult {
945        calculation,
946        metrics,
947        phases,
948        background,
949        intensities,
950        history,
951        termination_reason: termination,
952        checkpoint: final_checkpoint,
953    })
954}
955
956struct RestoredTofState {
957    phases: Vec<TofLeBailPhase>,
958    background: Option<TofChebyshevBackground>,
959    history: Vec<TofLeBailIterationRecord>,
960    first_iteration: usize,
961}
962
963fn restore_tof_state(
964    input: &TofLeBailInput,
965    options: &TofLeBailOptions,
966    checkpoint: Option<&TofLeBailCheckpoint>,
967) -> Result<RestoredTofState, TofLeBailError> {
968    let Some(checkpoint) = checkpoint else {
969        return Ok(RestoredTofState {
970            phases: initialize_intensities(input, options)?,
971            background: input.background.clone(),
972            history: Vec::new(),
973            first_iteration: 1,
974        });
975    };
976    checkpoint.validate_for(input, options)?;
977    let first_iteration = checkpoint
978        .completed_iterations
979        .checked_add(1)
980        .ok_or(TofLeBailError::AllocationOverflow)?;
981    Ok(RestoredTofState {
982        phases: checkpoint.phases.clone(),
983        background: checkpoint.background.clone(),
984        history: checkpoint.history.clone(),
985        first_iteration,
986    })
987}
988
989pub(crate) fn normal_tof_stop(error: RuntimeError) -> Result<TerminationReason, TofLeBailError> {
990    match error {
991        RuntimeError::Stopped(stop) => Ok(stop.reason),
992        other => Err(TofLeBailError::Runtime(other)),
993    }
994}
995
996pub(crate) fn initialize_intensities(
997    input: &TofLeBailInput,
998    options: &TofLeBailOptions,
999) -> Result<Vec<TofLeBailPhase>, TofLeBailError> {
1000    let current = flatten_intensities(&input.phases);
1001    if current.iter().any(|value| *value > 0.0) {
1002        let values = current
1003            .iter()
1004            .map(|value| value.max(options.initial_intensity_floor))
1005            .collect::<Vec<_>>();
1006        return install_intensities(&input.phases, &values);
1007    }
1008    let observed = input
1009        .pattern
1010        .observed_y
1011        .as_deref()
1012        .ok_or(TofLeBailError::MissingObservations)?;
1013    let mut background = input.pattern.background_y.clone();
1014    if let Some(residual) = &input.background {
1015        for (fixed, value) in background
1016            .iter_mut()
1017            .zip(residual.calculate(&input.pattern.tof_us)?)
1018        {
1019            *fixed += value;
1020        }
1021    }
1022    let widths = bin_integration_weights(&input.pattern.tof_us);
1023    let area = observed
1024        .iter()
1025        .zip(&background)
1026        .zip(widths)
1027        .map(|((observed, background), width)| (observed - background).max(0.0) * width)
1028        .sum::<f64>();
1029    let count = current.len().max(1);
1030    #[allow(clippy::cast_precision_loss)]
1031    let starting = (area / count as f64).max(options.initial_intensity_floor);
1032    install_intensities(&input.phases, &vec![starting; current.len()])
1033}
1034
1035pub(crate) fn redistribute(
1036    pattern: &TofPatternRecord,
1037    calculation: &TofLeBailCalculation,
1038    current: &[f64],
1039    options: &TofLeBailOptions,
1040) -> Result<Vec<f64>, TofLeBailError> {
1041    let observed = pattern
1042        .observed_y
1043        .as_deref()
1044        .ok_or(TofLeBailError::MissingObservations)?;
1045    let included = pattern
1046        .mask
1047        .clone()
1048        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1049    let ratio = observed
1050        .iter()
1051        .zip(&calculation.background_y)
1052        .zip(&calculation.profile_y)
1053        .zip(&included)
1054        .map(|(((observed, background), calculated), included)| {
1055            if *included && *calculated > options.minimum_calculated {
1056                (observed - background).max(0.0) / calculated
1057            } else {
1058                0.0
1059            }
1060        })
1061        .collect::<Vec<_>>();
1062    let mut weights = bin_integration_weights(&pattern.tof_us);
1063    if options.redistribution_use_uncertainty
1064        && let Some(uncertainty) = &pattern.uncertainty
1065    {
1066        for (weight, uncertainty) in weights.iter_mut().zip(uncertainty) {
1067            *weight /= uncertainty * uncertainty;
1068        }
1069    }
1070    for (weight, included) in weights.iter_mut().zip(&included) {
1071        if !included {
1072            *weight = 0.0;
1073        }
1074    }
1075    let local = &calculation.accumulation.derivatives.local;
1076    if local.peak_count() != current.len() {
1077        return Err(TofLeBailError::IntensityLengthMismatch);
1078    }
1079    let mut updated = vec![0.0; current.len()];
1080    for reflection in 0..current.len() {
1081        let begin = local.offsets[reflection];
1082        let end = local.offsets[reflection + 1];
1083        let start = local.starts[reflection];
1084        let mut denominator = 0.0;
1085        let mut numerator = 0.0;
1086        for active in begin..end {
1087            let sample = start + active - begin;
1088            let profile = local.values[active * local.parameter_count];
1089            let weighted_profile = weights[sample] * profile;
1090            denominator += weighted_profile;
1091            numerator += weighted_profile * ratio[sample];
1092        }
1093        let raw = if denominator > 0.0 {
1094            (current[reflection] * numerator / denominator).max(0.0)
1095        } else {
1096            current[reflection]
1097        };
1098        updated[reflection] =
1099            current[reflection] + options.redistribution_damping * (raw - current[reflection]);
1100    }
1101    Ok(updated)
1102}
1103
1104pub(crate) fn state_input(
1105    original: &TofLeBailInput,
1106    phases: Vec<TofLeBailPhase>,
1107    background: Option<TofChebyshevBackground>,
1108) -> Result<TofLeBailInput, TofLeBailError> {
1109    let mut result = TofLeBailInput::new(original.pattern.clone(), original.instrument, phases)?;
1110    result.background = background;
1111    result.validate()?;
1112    Ok(result)
1113}
1114
1115pub(crate) fn refine_background(
1116    pattern: &TofPatternRecord,
1117    profile_y: &[f64],
1118    background: Option<&TofChebyshevBackground>,
1119    options: &TofLeBailOptions,
1120) -> Result<Option<TofChebyshevBackground>, TofLeBailError> {
1121    let Some(background) = background else {
1122        return Ok(None);
1123    };
1124    let observed = pattern
1125        .observed_y
1126        .as_deref()
1127        .ok_or(TofLeBailError::MissingObservations)?;
1128    let basis = background.basis(&pattern.tof_us)?;
1129    if profile_y.len() != pattern.sample_count() || basis.rows != pattern.sample_count() {
1130        return Err(TofLeBailError::BackgroundBasisShape);
1131    }
1132    let included_count = (0..pattern.sample_count())
1133        .filter(|index| pattern.mask.as_ref().is_none_or(|mask| mask[*index]))
1134        .count();
1135    if included_count < basis.columns {
1136        return Err(TofLeBailError::InsufficientBackgroundObservations);
1137    }
1138    let count = included_count
1139        .checked_mul(basis.columns)
1140        .ok_or(TofLeBailError::AllocationOverflow)?;
1141    let mut design = Vec::with_capacity(count);
1142    let mut target = Vec::with_capacity(included_count);
1143    for sample in 0..pattern.sample_count() {
1144        if pattern.mask.as_ref().is_some_and(|mask| !mask[sample]) {
1145            continue;
1146        }
1147        let sigma = if options.use_uncertainty {
1148            pattern
1149                .uncertainty
1150                .as_ref()
1151                .map_or(1.0, |values| values[sample])
1152        } else {
1153            1.0
1154        };
1155        let row = basis
1156            .values
1157            .get(sample * basis.columns..(sample + 1) * basis.columns)
1158            .ok_or(TofLeBailError::BackgroundBasisShape)?;
1159        design.extend(row.iter().map(|value| value / sigma));
1160        target.push((observed[sample] - pattern.background_y[sample] - profile_y[sample]) / sigma);
1161    }
1162    let matrix = DMatrix::from_row_slice(included_count, basis.columns, &design);
1163    let target = DVector::from_vec(target);
1164    let coefficients = matrix
1165        .svd(true, true)
1166        .solve(&target, 1.0e-12)
1167        .map_err(|_| TofLeBailError::BackgroundLinearSolve)?;
1168    if coefficients.iter().any(|value| !value.is_finite()) {
1169        return Err(TofLeBailError::BackgroundLinearSolve);
1170    }
1171    background
1172        .with_coefficients(coefficients.as_slice().to_vec())
1173        .map(Some)
1174}
1175
1176pub(crate) fn maximum_background_change(
1177    previous: Option<&TofChebyshevBackground>,
1178    updated: Option<&TofChebyshevBackground>,
1179) -> f64 {
1180    previous.zip(updated).map_or(0.0, |(previous, updated)| {
1181        previous
1182            .coefficients
1183            .iter()
1184            .zip(&updated.coefficients)
1185            .map(|(previous, updated)| (updated - previous).abs())
1186            .fold(0.0_f64, f64::max)
1187    })
1188}
1189
1190pub(crate) fn flatten_intensities(phases: &[TofLeBailPhase]) -> Vec<f64> {
1191    phases
1192        .iter()
1193        .flat_map(|phase| phase.integrated_intensity.iter().copied())
1194        .collect()
1195}
1196
1197pub(crate) fn install_intensities(
1198    phases: &[TofLeBailPhase],
1199    values: &[f64],
1200) -> Result<Vec<TofLeBailPhase>, TofLeBailError> {
1201    if phases
1202        .iter()
1203        .map(|phase| phase.reflection_ids.len())
1204        .sum::<usize>()
1205        != values.len()
1206    {
1207        return Err(TofLeBailError::IntensityLengthMismatch);
1208    }
1209    let mut offset = 0;
1210    phases
1211        .iter()
1212        .map(|phase| {
1213            let end = offset + phase.reflection_ids.len();
1214            let result = phase.with_intensities(&values[offset..end]);
1215            offset = end;
1216            result
1217        })
1218        .collect()
1219}
1220
1221fn bin_integration_weights(x: &[f64]) -> Vec<f64> {
1222    match x.len() {
1223        0 => Vec::new(),
1224        1 => vec![1.0],
1225        count => (0..count)
1226            .map(|index| {
1227                if index == 0 {
1228                    0.5 * (x[1] - x[0])
1229                } else if index + 1 == count {
1230                    0.5 * (x[count - 1] - x[count - 2])
1231                } else {
1232                    0.5 * (x[index + 1] - x[index - 1])
1233                }
1234            })
1235            .collect(),
1236    }
1237}
1238
1239/// Invalid TOF Le Bail request or numerical state.
1240#[derive(Debug)]
1241pub enum TofLeBailError {
1242    /// TOF pattern validation failed.
1243    Pattern(DomainError),
1244    /// The observed intensity array is absent.
1245    MissingObservations,
1246    /// Instrument/profile evaluation failed.
1247    Profile(TofError),
1248    /// A phase invariant failed.
1249    InvalidPhase(&'static str),
1250    /// Numerical options are invalid.
1251    InvalidOptions,
1252    /// A Chebyshev background has invalid coefficients or domain.
1253    InvalidBackground,
1254    /// A background grid is empty, non-finite, or unsorted.
1255    InvalidBackgroundGrid,
1256    /// The TOF grid extends outside the background domain.
1257    BackgroundGridOutsideDomain,
1258    /// Replacement background coefficients have the wrong length.
1259    BackgroundCoefficientLengthMismatch,
1260    /// A background derivative basis has an inconsistent shape.
1261    BackgroundBasisShape,
1262    /// Too few included observations remain to determine every coefficient.
1263    InsufficientBackgroundObservations,
1264    /// The weighted Chebyshev least-squares solve failed.
1265    BackgroundLinearSolve,
1266    /// Flattened reflection intensities have the wrong length.
1267    IntensityLengthMismatch,
1268    /// Replacement reflection d-spacings have the wrong length.
1269    DSpacingLengthMismatch,
1270    /// Checked allocation arithmetic overflowed.
1271    AllocationOverflow,
1272    /// A continuation state disagrees with the immutable request contract.
1273    InvalidCheckpoint(&'static str),
1274    /// Residual evaluation failed.
1275    Residual(ResidualError),
1276    /// Execution policy construction failed.
1277    Execution(ExecutionPolicyError),
1278    /// Bounded runtime, cancellation, event, or checkpoint delivery failed.
1279    Runtime(RuntimeError),
1280}
1281
1282impl Display for TofLeBailError {
1283    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1284        match self {
1285            Self::Pattern(error) => Display::fmt(error, formatter),
1286            Self::MissingObservations => {
1287                formatter.write_str("observed_y is required for TOF Le Bail extraction")
1288            }
1289            Self::Profile(error) => Display::fmt(error, formatter),
1290            Self::InvalidPhase(message) | Self::InvalidCheckpoint(message) => {
1291                formatter.write_str(message)
1292            }
1293            Self::InvalidOptions => formatter.write_str("invalid TOF Le Bail options"),
1294            Self::InvalidBackground => {
1295                formatter.write_str("invalid TOF Chebyshev background coefficients or domain")
1296            }
1297            Self::InvalidBackgroundGrid => {
1298                formatter.write_str("TOF Chebyshev background grid must be finite and increasing")
1299            }
1300            Self::BackgroundGridOutsideDomain => {
1301                formatter.write_str("TOF grid extends outside the Chebyshev background domain")
1302            }
1303            Self::BackgroundCoefficientLengthMismatch => {
1304                formatter.write_str("TOF Chebyshev background coefficient length mismatch")
1305            }
1306            Self::BackgroundBasisShape => {
1307                formatter.write_str("TOF Chebyshev background basis shape mismatch")
1308            }
1309            Self::InsufficientBackgroundObservations => formatter.write_str(
1310                "TOF Chebyshev refinement has fewer included observations than coefficients",
1311            ),
1312            Self::BackgroundLinearSolve => {
1313                formatter.write_str("TOF Chebyshev weighted linear solve failed")
1314            }
1315            Self::IntensityLengthMismatch => {
1316                formatter.write_str("TOF reflection intensity length mismatch")
1317            }
1318            Self::DSpacingLengthMismatch => {
1319                formatter.write_str("TOF reflection d-spacing length mismatch")
1320            }
1321            Self::AllocationOverflow => formatter.write_str("TOF Le Bail allocation overflow"),
1322            Self::Residual(error) => Display::fmt(error, formatter),
1323            Self::Execution(error) => Display::fmt(error, formatter),
1324            Self::Runtime(error) => Display::fmt(error, formatter),
1325        }
1326    }
1327}
1328
1329impl Error for TofLeBailError {
1330    fn source(&self) -> Option<&(dyn Error + 'static)> {
1331        match self {
1332            Self::Pattern(error) => Some(error),
1333            Self::Profile(error) => Some(error),
1334            Self::Residual(error) => Some(error),
1335            Self::Execution(error) => Some(error),
1336            Self::Runtime(error) => Some(error),
1337            _ => None,
1338        }
1339    }
1340}
1341
1342impl From<TofError> for TofLeBailError {
1343    fn from(value: TofError) -> Self {
1344        Self::Profile(value)
1345    }
1346}
1347
1348impl From<ResidualError> for TofLeBailError {
1349    fn from(value: ResidualError) -> Self {
1350        Self::Residual(value)
1351    }
1352}
1353
1354impl From<ExecutionPolicyError> for TofLeBailError {
1355    fn from(value: ExecutionPolicyError) -> Self {
1356        Self::Execution(value)
1357    }
1358}
1359
1360impl From<RuntimeError> for TofLeBailError {
1361    fn from(value: RuntimeError) -> Self {
1362        Self::Runtime(value)
1363    }
1364}