Skip to main content

phasesmith_workflows/
lebail.rs

1//! Native fixed-reflection Le Bail integrated-intensity extraction.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6
7use nalgebra::{DMatrix, DVector};
8use phasesmith_core::{
9    Accumulation, ConstantWavelengthInstrument, CwContributionsError, GridView,
10    OwnedCwContributionArrays, OwnedCwContributions, ProfileError, SupportPolicy,
11    accumulate_cw_contributions_batch_with_context,
12};
13use phasesmith_crystallography::UnitCell;
14use phasesmith_execution::{ExecutionPolicy, ExecutionPolicyError};
15use phasesmith_model::{DomainError, PatternRecord};
16
17use crate::{
18    BackgroundError, BackgroundModel, Constraint, ConstraintError, ConstraintTransform,
19    DiagnosticValue, DifferentiableBackground, GeneratedLatticeDomain, LatticeError,
20    LatticeReflectionDomain, ParameterBounds, ParameterError, ParameterKey, ParameterSet,
21    ParameterSpec, RefinementEventKind, RefinementLimits, RefinementRuntime, ResidualError,
22    ResidualEvaluation, ResidualOptions, RuntimeError, TerminationReason, cw_lattice_geometry,
23    evaluate_residuals,
24};
25
26const INSTRUMENT_PARAMETER_NAMES: [&str; 5] = ["u_deg2", "v_deg2", "w_deg2", "x_deg", "y_deg"];
27const LATTICE_PARAMETER_NAMES: [&str; 6] = [
28    "a_angstrom",
29    "b_angstrom",
30    "c_angstrom",
31    "alpha_deg",
32    "beta_deg",
33    "gamma_deg",
34];
35
36/// One fixed reflection phase whose integrated intensities are extracted.
37#[derive(Clone, Debug, PartialEq)]
38pub struct LeBailPhase {
39    phase_id: String,
40    name: String,
41    reflection_ids: Vec<String>,
42    hkl: Vec<[i32; 3]>,
43    d_spacing_angstrom: Vec<f64>,
44    two_theta_deg: Vec<f64>,
45    integrated_intensity: Vec<f64>,
46    scale: f64,
47    preserve_unobserved: Vec<bool>,
48    cell: Option<UnitCell>,
49    reflection_domain: Option<LatticeReflectionDomain>,
50}
51
52impl LeBailPhase {
53    /// Validate and own one ordered fixed-reflection phase.
54    ///
55    /// `preserve_unobserved` marks generated reflections outside the currently
56    /// visible interval. Their checkpoint intensity is retained when their
57    /// finite profile support has no included samples. Pass an empty vector for
58    /// ordinary fixed reflection lists.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`LeBailError::InvalidPhase`] for invalid identity, shape, or
63    /// numerical state.
64    #[allow(clippy::too_many_arguments)]
65    pub fn new(
66        phase_id: impl Into<String>,
67        name: impl Into<String>,
68        reflection_ids: Vec<String>,
69        hkl: Vec<[i32; 3]>,
70        d_spacing_angstrom: Vec<f64>,
71        two_theta_deg: Vec<f64>,
72        integrated_intensity: Vec<f64>,
73        scale: f64,
74        preserve_unobserved: Vec<bool>,
75    ) -> Result<Self, LeBailError> {
76        let phase = Self {
77            phase_id: phase_id.into(),
78            name: name.into(),
79            reflection_ids,
80            hkl,
81            d_spacing_angstrom,
82            two_theta_deg,
83            integrated_intensity,
84            scale,
85            preserve_unobserved,
86            cell: None,
87            reflection_domain: None,
88        };
89        phase.validate()?;
90        Ok(phase)
91    }
92
93    /// Generate and own a bounded dynamic-lattice phase.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`LeBailError`] if the cell lies outside the domain, reflection
98    /// generation fails, or the resulting phase state is invalid.
99    pub fn from_lattice_domain(
100        phase_id: impl Into<String>,
101        name: impl Into<String>,
102        cell: UnitCell,
103        scale: f64,
104        reflection_domain: LatticeReflectionDomain,
105    ) -> Result<Self, LeBailError> {
106        let generated = reflection_domain
107            .generate(cell, None)
108            .map_err(LeBailError::Lattice)?;
109        let mut phase = Self::new(
110            phase_id,
111            name,
112            generated.reflection_ids.clone(),
113            generated.hkl.clone(),
114            generated.d_spacing_angstrom.clone(),
115            generated.two_theta_deg.clone(),
116            generated.integrated_intensity.clone(),
117            scale,
118            generated.visible.iter().map(|visible| !visible).collect(),
119        )?;
120        phase.cell = Some(cell);
121        phase.reflection_domain = Some(reflection_domain);
122        phase.validate()?;
123        Ok(phase)
124    }
125
126    fn validate(&self) -> Result<(), LeBailError> {
127        validate_stable_label("phase_id", &self.phase_id)?;
128        if self.name.trim().is_empty() {
129            return Err(invalid_phase("phase name must be non-empty"));
130        }
131        let count = self.reflection_ids.len();
132        if count == 0 {
133            return Err(invalid_phase("at least one reflection is required"));
134        }
135        if self.hkl.len() != count
136            || self.d_spacing_angstrom.len() != count
137            || self.two_theta_deg.len() != count
138            || self.integrated_intensity.len() != count
139            || (!self.preserve_unobserved.is_empty() && self.preserve_unobserved.len() != count)
140        {
141            return Err(invalid_phase("reflection arrays must have equal lengths"));
142        }
143        let mut identities = std::collections::BTreeSet::new();
144        for reflection_id in &self.reflection_ids {
145            validate_stable_label("reflection_id", reflection_id)?;
146            if !identities.insert(reflection_id) {
147                return Err(invalid_phase(
148                    "reflection IDs must be unique within a phase",
149                ));
150            }
151        }
152        if self
153            .d_spacing_angstrom
154            .iter()
155            .any(|value| !value.is_finite() || *value <= 0.0)
156        {
157            return Err(invalid_phase("d-spacings must be positive and finite"));
158        }
159        if self
160            .two_theta_deg
161            .iter()
162            .any(|value| !value.is_finite() || *value <= 0.0 || *value >= 180.0)
163        {
164            return Err(invalid_phase(
165                "reflection positions must lie strictly inside (0, 180) degrees",
166            ));
167        }
168        if self
169            .integrated_intensity
170            .iter()
171            .any(|value| !value.is_finite() || *value < 0.0)
172        {
173            return Err(invalid_phase(
174                "integrated intensities must be non-negative and finite",
175            ));
176        }
177        if !self.scale.is_finite() || self.scale < 0.0 {
178            return Err(invalid_phase("phase scale must be non-negative and finite"));
179        }
180        match (&self.cell, &self.reflection_domain) {
181            (None, None) => {}
182            (Some(cell), Some(domain)) => {
183                domain.validate_cell(*cell).map_err(LeBailError::Lattice)?;
184                if self.preserve_unobserved.len() != count {
185                    return Err(invalid_phase(
186                        "dynamic phases require one visibility marker per reflection",
187                    ));
188                }
189            }
190            _ => {
191                return Err(invalid_phase(
192                    "dynamic phases require both a cell and reflection domain",
193                ));
194            }
195        }
196        Ok(())
197    }
198
199    /// Borrow the stable phase ID.
200    #[must_use]
201    pub fn phase_id(&self) -> &str {
202        &self.phase_id
203    }
204
205    /// Borrow the display name.
206    #[must_use]
207    pub fn name(&self) -> &str {
208        &self.name
209    }
210
211    /// Borrow reflection IDs in calculation order.
212    #[must_use]
213    pub fn reflection_ids(&self) -> &[String] {
214        &self.reflection_ids
215    }
216
217    /// Borrow Miller indices in reflection order.
218    #[must_use]
219    pub fn hkl(&self) -> &[[i32; 3]] {
220        &self.hkl
221    }
222
223    /// Borrow d-spacings in ångströms.
224    #[must_use]
225    pub fn d_spacing_angstrom(&self) -> &[f64] {
226        &self.d_spacing_angstrom
227    }
228
229    /// Borrow fixed reflection positions in degrees `2theta`.
230    #[must_use]
231    pub fn two_theta_deg(&self) -> &[f64] {
232        &self.two_theta_deg
233    }
234
235    /// Borrow current integrated intensities.
236    #[must_use]
237    pub fn integrated_intensity(&self) -> &[f64] {
238        &self.integrated_intensity
239    }
240
241    /// Replace integrated intensities while preserving phase identity/geometry.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`LeBailError`] for a shape mismatch, negative value, or
246    /// non-finite value.
247    pub fn with_integrated_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
248        self.replace_intensities(values)
249    }
250
251    /// Return the phase scale.
252    #[must_use]
253    pub const fn scale(&self) -> f64 {
254        self.scale
255    }
256
257    /// Borrow the preserve-if-unobserved mask.
258    #[must_use]
259    pub fn preserve_unobserved(&self) -> &[bool] {
260        &self.preserve_unobserved
261    }
262
263    /// Return the current cell for a dynamic-lattice phase.
264    #[must_use]
265    pub const fn cell(&self) -> Option<UnitCell> {
266        self.cell
267    }
268
269    /// Borrow the guarded reflection domain for a dynamic-lattice phase.
270    #[must_use]
271    pub const fn reflection_domain(&self) -> Option<&LatticeReflectionDomain> {
272        self.reflection_domain.as_ref()
273    }
274
275    /// Regenerate a dynamic phase at another accepted bounded cell.
276    ///
277    /// Intensities transfer by stable reflection ID and new families use the
278    /// domain's declared initial intensity.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`LeBailError`] for a fixed phase, out-of-bounds cell, or
283    /// reflection-generation failure.
284    pub fn regenerate_lattice_at_cell(&self, cell: UnitCell) -> Result<Self, LeBailError> {
285        let domain = self
286            .reflection_domain
287            .as_ref()
288            .ok_or_else(|| invalid_phase("only dynamic phases have a lattice reflection domain"))?;
289        let previous = self
290            .reflection_ids
291            .iter()
292            .cloned()
293            .zip(self.integrated_intensity.iter().copied())
294            .collect::<BTreeMap<_, _>>();
295        let generated = domain
296            .generate(cell, Some(&previous))
297            .map_err(LeBailError::Lattice)?;
298        self.replace_generated_domain(cell, generated)
299    }
300
301    fn replace_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
302        if values.len() != self.integrated_intensity.len()
303            || values
304                .iter()
305                .any(|value| !value.is_finite() || *value < 0.0)
306        {
307            return Err(invalid_phase(
308                "replacement intensities must match and remain non-negative",
309            ));
310        }
311        let mut phase = self.clone();
312        phase.integrated_intensity.copy_from_slice(values);
313        Ok(phase)
314    }
315
316    fn replace_scale_and_positions(
317        &self,
318        scale: f64,
319        positions: Vec<f64>,
320    ) -> Result<Self, LeBailError> {
321        let mut phase = self.clone();
322        phase.scale = scale;
323        phase.two_theta_deg = positions;
324        phase.validate()?;
325        Ok(phase)
326    }
327
328    fn replace_cell_geometry(
329        &self,
330        cell: UnitCell,
331        wavelength_angstrom: f64,
332    ) -> Result<Self, LeBailError> {
333        let domain = self.reflection_domain.as_ref().ok_or_else(|| {
334            invalid_phase("lattice parameters require a bounded reflection domain")
335        })?;
336        domain.validate_cell(cell).map_err(LeBailError::Lattice)?;
337        let geometry = cw_lattice_geometry(
338            domain.parameterization(),
339            cell,
340            &self.hkl,
341            wavelength_angstrom,
342        )
343        .map_err(LeBailError::Lattice)?;
344        let mut phase = self.clone();
345        phase.cell = Some(cell);
346        phase.d_spacing_angstrom = geometry.d_spacing_angstrom;
347        phase.two_theta_deg = geometry.two_theta_deg;
348        phase.validate()?;
349        Ok(phase)
350    }
351
352    fn replace_generated_domain(
353        &self,
354        cell: UnitCell,
355        generated: GeneratedLatticeDomain,
356    ) -> Result<Self, LeBailError> {
357        let mut phase = self.clone();
358        phase.cell = Some(cell);
359        phase.reflection_ids = generated.reflection_ids;
360        phase.hkl = generated.hkl;
361        phase.d_spacing_angstrom = generated.d_spacing_angstrom;
362        phase.two_theta_deg = generated.two_theta_deg;
363        phase.integrated_intensity = generated.integrated_intensity;
364        phase.preserve_unobserved = generated
365            .visible
366            .into_iter()
367            .map(|visible| !visible)
368            .collect();
369        phase.validate()?;
370        Ok(phase)
371    }
372}
373
374/// Return the stable key for one supported CW profile coefficient.
375///
376/// # Errors
377///
378/// Returns [`LeBailError`] for an unsupported name.
379pub fn lebail_instrument_parameter_key(name: &str) -> Result<ParameterKey, LeBailError> {
380    if !INSTRUMENT_PARAMETER_NAMES.contains(&name) {
381        return Err(LeBailError::UnsupportedParameter {
382            label: format!("instrument[cw].{name}"),
383        });
384    }
385    ParameterKey::new("instrument", "cw", name).map_err(LeBailError::Parameter)
386}
387
388/// Return the stable key for a phase scale.
389///
390/// # Errors
391///
392/// Returns [`LeBailError`] for an invalid phase ID.
393pub fn lebail_phase_scale_key(phase_id: &str) -> Result<ParameterKey, LeBailError> {
394    ParameterKey::new("phase", phase_id, "scale").map_err(LeBailError::Parameter)
395}
396
397/// Return the stable key for one refinable residual-background coefficient.
398///
399/// # Errors
400///
401/// Returns [`LeBailError`] when an identity segment is invalid.
402pub fn lebail_background_parameter_key(
403    background_id: &str,
404    name: &str,
405) -> Result<ParameterKey, LeBailError> {
406    ParameterKey::new("background", background_id, name).map_err(LeBailError::Parameter)
407}
408
409/// Return the stable key for one symmetry-independent lattice variable.
410///
411/// # Errors
412///
413/// Returns [`LeBailError`] for an unsupported name or invalid phase ID.
414pub fn lebail_lattice_parameter_key(
415    phase_id: &str,
416    name: &str,
417) -> Result<ParameterKey, LeBailError> {
418    if !LATTICE_PARAMETER_NAMES.contains(&name) {
419        return Err(LeBailError::UnsupportedParameter {
420            label: format!("lattice[{phase_id}].{name}"),
421        });
422    }
423    ParameterKey::new("lattice", phase_id, name).map_err(LeBailError::Parameter)
424}
425
426/// Return the stable key for one independent reflection position.
427///
428/// # Errors
429///
430/// Returns [`LeBailError`] for invalid identity segments.
431pub fn lebail_reflection_position_key(
432    phase_id: &str,
433    reflection_id: &str,
434) -> Result<ParameterKey, LeBailError> {
435    ParameterKey::new(
436        "reflection",
437        format!("{phase_id}/{reflection_id}"),
438        "two_theta_deg",
439    )
440    .map_err(LeBailError::Parameter)
441}
442
443/// Build bounded typed specifications for selected fixed-geometry parameters.
444///
445/// # Errors
446///
447/// Returns [`LeBailError`] for unsupported names or invalid phase state.
448pub fn build_lebail_parameter_set(
449    instrument: ConstantWavelengthInstrument,
450    phases: &[LeBailPhase],
451    instrument_parameters: &[&str],
452    phase_scales: bool,
453    reflection_positions: bool,
454) -> Result<ParameterSet, LeBailError> {
455    build_lebail_parameter_set_with_lattice(
456        instrument,
457        phases,
458        instrument_parameters,
459        phase_scales,
460        reflection_positions,
461        false,
462    )
463}
464
465/// Build bounded typed specifications including optional lattice variables.
466///
467/// # Errors
468///
469/// Returns [`LeBailError`] for unsupported selections or invalid phase state.
470pub fn build_lebail_parameter_set_with_lattice(
471    instrument: ConstantWavelengthInstrument,
472    phases: &[LeBailPhase],
473    instrument_parameters: &[&str],
474    phase_scales: bool,
475    reflection_positions: bool,
476    lattice_parameters: bool,
477) -> Result<ParameterSet, LeBailError> {
478    if lattice_parameters && reflection_positions {
479        return Err(invalid_phase(
480            "lattice parameters and independent reflection positions are redundant",
481        ));
482    }
483    let mut specs = Vec::new();
484    for name in instrument_parameters {
485        let key = lebail_instrument_parameter_key(name)?;
486        let value = instrument_parameter(instrument, name)
487            .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
488        specs.push(
489            ParameterSpec::new(
490                key,
491                value,
492                if name.ends_with("deg2") {
493                    "degree^2"
494                } else {
495                    "degree"
496                },
497                ParameterBounds::default(),
498                value.abs().max(if name.ends_with("deg2") {
499                    1.0e-5
500                } else {
501                    1.0e-4
502                }),
503                true,
504            )
505            .map_err(LeBailError::Parameter)?,
506        );
507    }
508    for phase in phases {
509        if lattice_parameters {
510            append_lattice_parameter_specs(&mut specs, phase)?;
511        }
512        if phase_scales {
513            specs.push(
514                ParameterSpec::new(
515                    lebail_phase_scale_key(phase.phase_id())?,
516                    phase.scale(),
517                    "dimensionless",
518                    ParameterBounds::new(0.0, f64::INFINITY).map_err(LeBailError::Parameter)?,
519                    phase.scale().max(1.0),
520                    true,
521                )
522                .map_err(LeBailError::Parameter)?,
523            );
524        }
525        if reflection_positions {
526            if phase.reflection_domain.is_some() {
527                return Err(invalid_phase(
528                    "independent reflection positions require fixed-topology phases",
529                ));
530            }
531            for (reflection_id, position) in phase.reflection_ids.iter().zip(&phase.two_theta_deg) {
532                specs.push(
533                    ParameterSpec::new(
534                        lebail_reflection_position_key(phase.phase_id(), reflection_id)?,
535                        *position,
536                        "degree_2theta",
537                        ParameterBounds::new(
538                            f64::from_bits(1),
539                            f64::from_bits(180.0_f64.to_bits() - 1),
540                        )
541                        .map_err(LeBailError::Parameter)?,
542                        0.01,
543                        true,
544                    )
545                    .map_err(LeBailError::Parameter)?,
546                );
547            }
548        }
549    }
550    ParameterSet::new(specs).map_err(LeBailError::Parameter)
551}
552
553fn append_lattice_parameter_specs(
554    specs: &mut Vec<ParameterSpec>,
555    phase: &LeBailPhase,
556) -> Result<(), LeBailError> {
557    let cell = phase
558        .cell
559        .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
560    let domain = phase
561        .reflection_domain
562        .as_ref()
563        .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
564    let values = domain
565        .parameterization()
566        .values_from_cell(cell)
567        .map_err(LeBailError::Lattice)?;
568    for (((name, value), lower), upper) in domain
569        .parameterization()
570        .parameter_names()
571        .iter()
572        .zip(values)
573        .zip(domain.bounds().lower())
574        .zip(domain.bounds().upper())
575    {
576        specs.push(
577            ParameterSpec::new(
578                lebail_lattice_parameter_key(phase.phase_id(), name)?,
579                value,
580                if name.ends_with("_angstrom") {
581                    "angstrom"
582                } else {
583                    "degree"
584                },
585                ParameterBounds::new(*lower, *upper).map_err(LeBailError::Parameter)?,
586                value.abs().max(1.0),
587                true,
588            )
589            .map_err(LeBailError::Parameter)?,
590        );
591    }
592    Ok(())
593}
594
595/// Observations, instrument, and ordered fixed-reflection phases.
596#[derive(Clone, Debug, PartialEq)]
597pub struct LeBailInput {
598    /// Observed pattern and fixed supplied background.
599    pub pattern: PatternRecord,
600    /// Constant-wavelength U/V/W/X/Y profile.
601    pub instrument: ConstantWavelengthInstrument,
602    /// Ordered non-empty phase list.
603    pub phases: Vec<LeBailPhase>,
604    /// Optional refinable analytical correction added to the fixed background.
605    pub background: Option<BackgroundModel>,
606    /// Optional typed profile parameter set.
607    pub parameters: Option<ParameterSet>,
608    /// Ordered fixed/affine/linear parameter constraints.
609    pub constraints: Vec<Constraint>,
610}
611
612impl LeBailInput {
613    /// Validate a complete fixed-reflection Le Bail request.
614    ///
615    /// # Errors
616    ///
617    /// Returns [`LeBailError`] for invalid observations, instrument, phase
618    /// state, or repeated phase IDs.
619    pub fn new(
620        pattern: PatternRecord,
621        instrument: ConstantWavelengthInstrument,
622        phases: Vec<LeBailPhase>,
623    ) -> Result<Self, LeBailError> {
624        pattern.validate().map_err(LeBailError::Pattern)?;
625        if pattern.observed_y.is_none() {
626            return Err(LeBailError::MissingObservations);
627        }
628        instrument
629            .validate()
630            .map_err(|error| LeBailError::Profile {
631                message: error.to_string(),
632            })?;
633        if phases.is_empty() {
634            return Err(invalid_phase("at least one phase is required"));
635        }
636        let mut phase_ids = std::collections::BTreeSet::new();
637        for phase in &phases {
638            phase.validate()?;
639            if phase.reflection_domain.as_ref().is_some_and(|domain| {
640                domain.wavelength_angstrom().to_bits() != instrument.wavelength_angstrom.to_bits()
641            }) {
642                return Err(invalid_phase(
643                    "dynamic phase wavelength must match the Le Bail instrument",
644                ));
645            }
646            if !phase_ids.insert(phase.phase_id()) {
647                return Err(invalid_phase("phase IDs must be unique"));
648            }
649        }
650        Ok(Self {
651            pattern,
652            instrument,
653            phases,
654            background: None,
655            parameters: None,
656            constraints: Vec::new(),
657        })
658    }
659
660    /// Validate a request with optional analytical profile parameters.
661    ///
662    /// # Errors
663    ///
664    /// Returns [`LeBailError`] for unsupported keys, domain/value mismatch, or
665    /// an invalid constraint graph.
666    pub fn new_with_parameters(
667        pattern: PatternRecord,
668        instrument: ConstantWavelengthInstrument,
669        phases: Vec<LeBailPhase>,
670        parameters: ParameterSet,
671        constraints: Vec<Constraint>,
672    ) -> Result<Self, LeBailError> {
673        let mut input = Self::new(pattern, instrument, phases)?;
674        validate_parameter_selection(&input.phases, input.background.as_ref(), &parameters)?;
675        domain_parameter_values(
676            input.instrument,
677            &input.phases,
678            input.background.as_ref(),
679            &parameters,
680        )?;
681        ConstraintTransform::new(parameters.clone(), constraints.clone())
682            .map_err(LeBailError::Constraint)?;
683        input.parameters = Some(parameters);
684        input.constraints = constraints;
685        Ok(input)
686    }
687
688    /// Attach a refinable analytical correction on top of the fixed supplied background.
689    ///
690    /// The model coefficients are appended to the existing parameter set in stable order.
691    /// This makes a Smooth Bruckner array in [`PatternRecord`] the fixed broad baseline,
692    /// while the analytical model captures low-order residual curvature.
693    ///
694    /// # Errors
695    ///
696    /// Returns [`LeBailError`] for an invalid model/grid or duplicate parameter identity.
697    pub fn with_refinable_background(
698        mut self,
699        background: BackgroundModel,
700    ) -> Result<Self, LeBailError> {
701        background
702            .basis(&self.pattern.x_deg)
703            .map_err(LeBailError::Background)?;
704        background
705            .calculate(&self.pattern.x_deg)
706            .map_err(LeBailError::Background)?;
707        let names = background.parameter_names();
708        let coefficients = background.coefficients();
709        let bounds = background.parameter_bounds();
710        let refine_in_nonlinear_step = !background.basis_is_invariant();
711        let mut specs = self
712            .parameters
713            .as_ref()
714            .map_or_else(Vec::new, |parameters| parameters.specs().to_vec());
715        let background_scale = self
716            .pattern
717            .background_y
718            .iter()
719            .map(|value| value.abs())
720            .fold(1.0_f64, f64::max);
721        for ((name, value), bounds) in names.iter().zip(coefficients).zip(bounds) {
722            specs.push(
723                ParameterSpec::new(
724                    lebail_background_parameter_key(background.background_id(), name)?,
725                    value,
726                    "intensity",
727                    bounds,
728                    value.abs().max(background_scale),
729                    refine_in_nonlinear_step,
730                )
731                .map_err(LeBailError::Parameter)?,
732            );
733        }
734        self.parameters = Some(ParameterSet::new(specs).map_err(LeBailError::Parameter)?);
735        self.background = Some(background);
736        validate_parameter_selection(
737            &self.phases,
738            self.background.as_ref(),
739            self.parameters
740                .as_ref()
741                .ok_or(LeBailError::InternalInvariant)?,
742        )?;
743        ConstraintTransform::new(
744            self.parameters
745                .clone()
746                .ok_or(LeBailError::InternalInvariant)?,
747            self.constraints.clone(),
748        )
749        .map_err(LeBailError::Constraint)?;
750        Ok(self)
751    }
752}
753
754/// Deterministic controls for fixed-reflection extraction.
755#[derive(Clone, Debug, PartialEq)]
756pub struct LeBailOptions {
757    /// Maximum accepted iterations.
758    pub max_iterations: usize,
759    /// Minimum accepted iterations before convergence.
760    pub min_iterations: usize,
761    /// Maximum relative integrated-intensity change for convergence.
762    pub intensity_tolerance: f64,
763    /// Absolute Rwp change for convergence.
764    pub rwp_tolerance: f64,
765    /// Multiplicative redistribution damping in `(0, 1]`.
766    pub redistribution_damping: f64,
767    /// Minimum calculated profile accepted in the observed/calculated ratio.
768    pub minimum_calculated: f64,
769    /// Positive starting and relative-change denominator floor.
770    pub initial_intensity_floor: f64,
771    /// Whether supplied one-sigma uncertainty is used.
772    pub use_uncertainty: bool,
773    /// Non-negative diagonal regularization for profile normal equations.
774    pub profile_damping: f64,
775    /// Maximum absolute free-parameter step in scaled coordinates.
776    pub max_scaled_parameter_step: f64,
777    /// Number of profile-step halvings after the initial candidate.
778    pub max_profile_backtracks: usize,
779    /// Correlation threshold used by optional unresolved-group diagnostics.
780    pub unresolved_correlation: f64,
781    /// Whether coincident reflection rank diagnostics are calculated.
782    pub diagnose_rank_deficiency: bool,
783    /// Finite profile support in FWHM units.
784    pub support_fwhm: f64,
785    /// Persistent bounded native worker policy.
786    pub execution: ExecutionPolicy,
787}
788
789impl LeBailOptions {
790    /// Validate all convergence and execution controls.
791    ///
792    /// # Errors
793    ///
794    /// Returns [`LeBailError::InvalidOptions`] for an invalid control.
795    #[allow(clippy::too_many_arguments)]
796    pub fn new(
797        max_iterations: usize,
798        min_iterations: usize,
799        intensity_tolerance: f64,
800        rwp_tolerance: f64,
801        redistribution_damping: f64,
802        minimum_calculated: f64,
803        initial_intensity_floor: f64,
804        use_uncertainty: bool,
805        unresolved_correlation: f64,
806        diagnose_rank_deficiency: bool,
807        support_fwhm: f64,
808        execution: ExecutionPolicy,
809    ) -> Result<Self, LeBailError> {
810        let options = Self {
811            max_iterations,
812            min_iterations,
813            intensity_tolerance,
814            rwp_tolerance,
815            redistribution_damping,
816            minimum_calculated,
817            initial_intensity_floor,
818            use_uncertainty,
819            profile_damping: 1.0e-10,
820            max_scaled_parameter_step: 0.25,
821            max_profile_backtracks: 8,
822            unresolved_correlation,
823            diagnose_rank_deficiency,
824            support_fwhm,
825            execution,
826        };
827        options.validate()?;
828        Ok(options)
829    }
830
831    fn validate(&self) -> Result<(), LeBailError> {
832        if self.max_iterations == 0
833            || self.min_iterations == 0
834            || self.min_iterations > self.max_iterations
835        {
836            return Err(invalid_options(
837                "iteration counts must be positive and minimum must not exceed maximum",
838            ));
839        }
840        for (name, value) in [
841            ("intensity_tolerance", self.intensity_tolerance),
842            ("rwp_tolerance", self.rwp_tolerance),
843            ("minimum_calculated", self.minimum_calculated),
844            ("initial_intensity_floor", self.initial_intensity_floor),
845            ("support_fwhm", self.support_fwhm),
846        ] {
847            if !value.is_finite() || value <= 0.0 {
848                return Err(LeBailError::InvalidOptions {
849                    message: format!("{name} must be positive and finite"),
850                });
851            }
852        }
853        if !self.redistribution_damping.is_finite()
854            || self.redistribution_damping <= 0.0
855            || self.redistribution_damping > 1.0
856        {
857            return Err(invalid_options("redistribution_damping must lie in (0, 1]"));
858        }
859        if !self.unresolved_correlation.is_finite()
860            || !(0.0..=1.0).contains(&self.unresolved_correlation)
861        {
862            return Err(invalid_options("unresolved_correlation must lie in [0, 1]"));
863        }
864        if !self.profile_damping.is_finite() || self.profile_damping < 0.0 {
865            return Err(invalid_options(
866                "profile_damping must be non-negative and finite",
867            ));
868        }
869        if !self.max_scaled_parameter_step.is_finite() || self.max_scaled_parameter_step <= 0.0 {
870            return Err(invalid_options(
871                "max_scaled_parameter_step must be positive and finite",
872            ));
873        }
874        Ok(())
875    }
876
877    /// Replace the native profile-solver controls after validation.
878    ///
879    /// # Errors
880    ///
881    /// Returns [`LeBailError`] for invalid damping or step controls.
882    pub fn with_profile_controls(
883        mut self,
884        profile_damping: f64,
885        max_scaled_parameter_step: f64,
886        max_profile_backtracks: usize,
887    ) -> Result<Self, LeBailError> {
888        self.profile_damping = profile_damping;
889        self.max_scaled_parameter_step = max_scaled_parameter_step;
890        self.max_profile_backtracks = max_profile_backtracks;
891        self.validate()?;
892        Ok(self)
893    }
894
895    /// Construct the scripting-compatible defaults with an explicit policy.
896    ///
897    /// # Errors
898    ///
899    /// Returns [`LeBailError`] if the controls cannot be constructed.
900    pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, LeBailError> {
901        Self::new(
902            50,
903            2,
904            1.0e-6,
905            1.0e-8,
906            1.0,
907            1.0e-15,
908            1.0e-12,
909            true,
910            1.0 - 1.0e-10,
911            false,
912            20.0,
913            execution,
914        )
915    }
916}
917
918/// One display-ready phase curve.
919#[derive(Clone, Debug, PartialEq)]
920pub struct PhasePatternComponent {
921    /// Stable phase ID.
922    pub phase_id: String,
923    /// Sample-aligned phase contribution.
924    pub y: Vec<f64>,
925}
926
927/// Native fixed-phase pattern result used by extraction and adapters.
928#[derive(Clone, Debug, PartialEq)]
929pub struct LeBailCalculation {
930    /// Profile plus combined fixed and analytical background.
931    pub y: Vec<f64>,
932    /// Sum of all phase profiles.
933    pub profile_y: Vec<f64>,
934    /// Combined fixed supplied baseline and analytical residual background.
935    pub background_y: Vec<f64>,
936    /// Sparse local and dense global profile derivatives.
937    pub accumulation: Accumulation,
938    /// `(phase_id, reflection_id)` in local-Jacobian order.
939    pub reflection_keys: Vec<(String, String)>,
940    /// Prefix sum of phase reflection counts.
941    pub phase_offsets: Vec<usize>,
942    /// One diagnostic curve per phase.
943    pub phase_components: Vec<PhasePatternComponent>,
944}
945
946/// One non-negative multiplicative redistribution result.
947#[derive(Clone, Debug, PartialEq)]
948pub struct IntensityExtractionResult {
949    /// New integrated intensities in reflection order.
950    pub intensities: Vec<f64>,
951    /// Largest floored relative intensity change.
952    pub maximum_relative_change: f64,
953    /// Reflection keys without included finite support.
954    pub unobserved_reflections: Vec<(String, String)>,
955}
956
957/// One accepted physical profile-parameter change.
958#[derive(Clone, Debug, PartialEq)]
959pub struct ParameterChange {
960    /// Stable parameter identity.
961    pub key: ParameterKey,
962    /// Physical value before the step.
963    pub before: f64,
964    /// Physical value after the step.
965    pub after: f64,
966    /// Change divided by the parameter scale.
967    pub scaled_change: f64,
968}
969
970/// One immutable accepted fixed-reflection iteration.
971#[derive(Clone, Debug, PartialEq)]
972pub struct LeBailIterationRecord {
973    /// One-based attempted iteration.
974    pub iteration: usize,
975    /// Unweighted profile residual.
976    pub rp: f64,
977    /// Weighted profile residual.
978    pub rwp: f64,
979    /// Weighted residual sum of squares.
980    pub chi_square: f64,
981    /// Chi-square per positive residual degree of freedom.
982    pub reduced_chi_square: f64,
983    /// Largest relative integrated-intensity change.
984    pub maximum_relative_intensity_change: f64,
985    /// Euclidean norm of the accepted scaled profile step.
986    pub scaled_profile_step_norm: f64,
987    /// Accepted physical parameter changes.
988    pub parameter_changes: Vec<ParameterChange>,
989    /// Iteration warnings in deterministic order.
990    pub warnings: Vec<String>,
991}
992
993/// Final stable reflection identity and intensity.
994#[derive(Clone, Debug, PartialEq)]
995pub struct ReflectionIntensity {
996    /// Stable phase ID.
997    pub phase_id: String,
998    /// Stable reflection ID.
999    pub reflection_id: String,
1000    /// Non-negative integrated intensity.
1001    pub integrated_intensity: f64,
1002}
1003
1004/// Numerically coincident profile columns and their matrix rank.
1005#[derive(Clone, Debug, PartialEq, Eq)]
1006pub struct CoincidentReflectionGroup {
1007    /// Stable reflection keys.
1008    pub reflection_keys: Vec<(String, String)>,
1009    /// Numerical rank of the joined support matrix.
1010    pub rank: usize,
1011}
1012
1013/// Complete immutable continuation state for the fixed-reflection workflow.
1014#[derive(Clone, Debug, PartialEq)]
1015pub struct LeBailCheckpoint {
1016    /// Number of accepted iterations.
1017    pub completed_iterations: usize,
1018    /// Current phase records and integrated intensities.
1019    pub phases: Vec<LeBailPhase>,
1020    /// Current instrument, including accepted profile changes.
1021    pub instrument: ConstantWavelengthInstrument,
1022    /// Current refinable residual background.
1023    pub background: Option<BackgroundModel>,
1024    /// Flattened current integrated intensities.
1025    pub intensities: Vec<f64>,
1026    /// Current profile parameter set.
1027    pub parameters: Option<ParameterSet>,
1028    /// Rwp from the last non-converged accepted iteration.
1029    pub previous_rwp: f64,
1030    /// Complete accepted deterministic history.
1031    pub history: Vec<LeBailIterationRecord>,
1032}
1033
1034impl LeBailCheckpoint {
1035    fn validate(&self) -> Result<(), LeBailError> {
1036        if self.completed_iterations != self.history.len() {
1037            return Err(LeBailError::InvalidCheckpoint {
1038                message: "checkpoint iteration count must equal its history length".to_owned(),
1039            });
1040        }
1041        if self.previous_rwp.is_nan() || self.previous_rwp == f64::NEG_INFINITY {
1042            return Err(LeBailError::InvalidCheckpoint {
1043                message: "checkpoint previous_rwp must be finite or positive infinity".to_owned(),
1044            });
1045        }
1046        for phase in &self.phases {
1047            phase.validate()?;
1048        }
1049        self.instrument
1050            .validate()
1051            .map_err(|error| LeBailError::Profile {
1052                message: error.to_string(),
1053            })?;
1054        if self.phases.iter().any(|phase| {
1055            phase.reflection_domain.as_ref().is_some_and(|domain| {
1056                domain.wavelength_angstrom().to_bits()
1057                    != self.instrument.wavelength_angstrom.to_bits()
1058            })
1059        }) {
1060            return Err(LeBailError::InvalidCheckpoint {
1061                message: "checkpoint dynamic phase wavelength must match its instrument".to_owned(),
1062            });
1063        }
1064        if let Some(parameters) = &self.parameters {
1065            validate_parameter_selection(&self.phases, self.background.as_ref(), parameters)?;
1066            let domain_values = domain_parameter_values(
1067                self.instrument,
1068                &self.phases,
1069                self.background.as_ref(),
1070                parameters,
1071            )?;
1072            if domain_values != parameters.values() {
1073                return Err(LeBailError::InvalidCheckpoint {
1074                    message: "checkpoint parameters disagree with its live domain".to_owned(),
1075                });
1076            }
1077        }
1078        let expected = self.phases.iter().map(reflection_count).sum::<usize>();
1079        if self.intensities.len() != expected
1080            || self
1081                .intensities
1082                .iter()
1083                .any(|value| !value.is_finite() || *value < 0.0)
1084        {
1085            return Err(LeBailError::InvalidCheckpoint {
1086                message: "checkpoint intensities must match its phases".to_owned(),
1087            });
1088        }
1089        if flatten_intensities(&self.phases) != self.intensities {
1090            return Err(LeBailError::InvalidCheckpoint {
1091                message: "checkpoint phase and flattened intensities disagree".to_owned(),
1092            });
1093        }
1094        if self
1095            .history
1096            .iter()
1097            .enumerate()
1098            .any(|(index, record)| record.iteration != index + 1)
1099        {
1100            return Err(LeBailError::InvalidCheckpoint {
1101                message: "checkpoint history iterations must be contiguous and one-based"
1102                    .to_owned(),
1103            });
1104        }
1105        Ok(())
1106    }
1107}
1108
1109/// Complete native fixed-reflection Le Bail result.
1110#[derive(Clone, Debug, PartialEq)]
1111pub struct LeBailResult {
1112    /// Final calculated pattern and sparse derivative storage.
1113    pub calculation: LeBailCalculation,
1114    /// Final phases.
1115    pub phases: Vec<LeBailPhase>,
1116    /// Final constant-wavelength profile.
1117    pub instrument: ConstantWavelengthInstrument,
1118    /// Final refinable residual background added to the fixed pattern baseline.
1119    pub background: Option<BackgroundModel>,
1120    /// Final labeled integrated intensities.
1121    pub intensities: Vec<ReflectionIntensity>,
1122    /// Final residual arrays and metrics.
1123    pub metrics: ResidualEvaluation,
1124    /// Accepted deterministic iteration history.
1125    pub history: Vec<LeBailIterationRecord>,
1126    /// Stable termination category.
1127    pub termination_reason: TerminationReason,
1128    /// Optional unresolved reflection diagnostics.
1129    pub rank_deficient_groups: Vec<CoincidentReflectionGroup>,
1130    /// Final typed profile parameters.
1131    pub parameters: Option<ParameterSet>,
1132    /// Row-major free-parameter covariance, if identifiable.
1133    pub covariance: Option<CovarianceMatrix>,
1134    /// Complete restart state.
1135    pub checkpoint: LeBailCheckpoint,
1136}
1137
1138/// Square row-major covariance over scaled free parameters.
1139///
1140/// The inverse weighted normal matrix is unscaled for supplied uncertainties;
1141/// unit-weight fits estimate their noise scale from the reduced chi-square.
1142#[derive(Clone, Debug, PartialEq)]
1143pub struct CovarianceMatrix {
1144    /// Matrix dimension.
1145    pub size: usize,
1146    /// Row-major values with length `size * size`.
1147    pub values: Vec<f64>,
1148}
1149
1150/// Calculate all fixed phases through one native fused accumulation.
1151///
1152/// # Errors
1153///
1154/// Returns [`LeBailError`] for invalid pattern/profile state or allocation.
1155pub fn calculate_lebail_pattern(
1156    pattern: &PatternRecord,
1157    instrument: ConstantWavelengthInstrument,
1158    phases: &[LeBailPhase],
1159    support_fwhm: f64,
1160    execution: &ExecutionPolicy,
1161) -> Result<LeBailCalculation, LeBailError> {
1162    calculate_lebail_pattern_with_background(
1163        pattern,
1164        instrument,
1165        phases,
1166        None,
1167        support_fwhm,
1168        execution,
1169    )
1170}
1171
1172/// Calculate all fixed phases plus an optional analytical residual background.
1173///
1174/// The analytical values are added to, never substituted for, the fixed
1175/// background stored by [`PatternRecord`].
1176///
1177/// # Errors
1178///
1179/// Returns [`LeBailError`] for invalid pattern, profile, or background state.
1180pub fn calculate_lebail_pattern_with_background(
1181    pattern: &PatternRecord,
1182    instrument: ConstantWavelengthInstrument,
1183    phases: &[LeBailPhase],
1184    background: Option<&BackgroundModel>,
1185    support_fwhm: f64,
1186    execution: &ExecutionPolicy,
1187) -> Result<LeBailCalculation, LeBailError> {
1188    pattern.validate().map_err(LeBailError::Pattern)?;
1189    if phases.is_empty() {
1190        return Err(invalid_phase("at least one phase is required"));
1191    }
1192    if !support_fwhm.is_finite() || support_fwhm <= 0.0 {
1193        return Err(invalid_options("support_fwhm must be positive and finite"));
1194    }
1195    let reflection_count = phases.iter().map(reflection_count).sum::<usize>();
1196    let mut positions = Vec::with_capacity(reflection_count);
1197    let mut intensities = Vec::with_capacity(reflection_count);
1198    let mut multipliers = Vec::with_capacity(reflection_count);
1199    let mut reflection_keys = Vec::with_capacity(reflection_count);
1200    let mut phase_offsets = Vec::with_capacity(phases.len() + 1);
1201    let phase_derivative_count = phases
1202        .len()
1203        .checked_mul(reflection_count)
1204        .ok_or(LeBailError::SizeOverflow)?;
1205    let mut derivative_multipliers = vec![0.0; phase_derivative_count];
1206    phase_offsets.push(0);
1207    for (phase_index, phase) in phases.iter().enumerate() {
1208        phase.validate()?;
1209        let begin = positions.len();
1210        positions.extend_from_slice(&phase.two_theta_deg);
1211        intensities.extend_from_slice(&phase.integrated_intensity);
1212        multipliers.extend(std::iter::repeat_n(phase.scale, reflection_count_of(phase)));
1213        reflection_keys.extend(
1214            phase
1215                .reflection_ids
1216                .iter()
1217                .map(|reflection_id| (phase.phase_id.clone(), reflection_id.clone())),
1218        );
1219        let end = positions.len();
1220        derivative_multipliers
1221            [phase_index * reflection_count + begin..phase_index * reflection_count + end]
1222            .fill(1.0);
1223        phase_offsets.push(end);
1224    }
1225    let contributions = OwnedCwContributions::new(
1226        reflection_count,
1227        phases.len(),
1228        OwnedCwContributionArrays {
1229            gaussian_variance_deg2: vec![0.0; reflection_count],
1230            lorentzian_fwhm_deg: vec![0.0; reflection_count],
1231            intensity_multiplier: multipliers,
1232            d_gaussian_variance_d_position: vec![0.0; reflection_count],
1233            d_lorentzian_fwhm_d_position: vec![0.0; reflection_count],
1234            d_intensity_multiplier_d_position: vec![0.0; reflection_count],
1235            d_gaussian_variance_d_parameters: vec![0.0; phase_derivative_count],
1236            d_lorentzian_fwhm_d_parameters: vec![0.0; phase_derivative_count],
1237            d_intensity_multiplier_d_parameters: derivative_multipliers,
1238        },
1239    )
1240    .map_err(LeBailError::Calculation)?;
1241    let grid = GridView::new(&pattern.x_deg).map_err(LeBailError::Grid)?;
1242    let accumulation = accumulate_cw_contributions_batch_with_context(
1243        grid,
1244        &positions,
1245        &intensities,
1246        instrument,
1247        contributions.as_view(),
1248        SupportPolicy::FwhmMultiple(support_fwhm),
1249        execution.context(),
1250    )
1251    .map_err(LeBailError::Calculation)?;
1252    let profile_y = accumulation.y.clone();
1253    let background_y = combined_background_values(pattern, background)?;
1254    let y = profile_y
1255        .iter()
1256        .zip(&background_y)
1257        .map(|(profile, background)| profile + background)
1258        .collect::<Vec<_>>();
1259    let phase_components = build_phase_components(
1260        phases,
1261        &phase_offsets,
1262        &intensities,
1263        &accumulation,
1264        pattern.sample_count(),
1265    );
1266    Ok(LeBailCalculation {
1267        y,
1268        profile_y,
1269        background_y,
1270        accumulation,
1271        reflection_keys,
1272        phase_offsets,
1273        phase_components,
1274    })
1275}
1276
1277fn combined_background_values(
1278    pattern: &PatternRecord,
1279    background: Option<&BackgroundModel>,
1280) -> Result<Vec<f64>, LeBailError> {
1281    let mut values = pattern.background_y.clone();
1282    if let Some(background) = background {
1283        for (fixed, residual) in values.iter_mut().zip(
1284            background
1285                .calculate(&pattern.x_deg)
1286                .map_err(LeBailError::Background)?,
1287        ) {
1288            *fixed += residual;
1289        }
1290    }
1291    Ok(values)
1292}
1293
1294fn build_phase_components(
1295    phases: &[LeBailPhase],
1296    phase_offsets: &[usize],
1297    intensities: &[f64],
1298    accumulation: &Accumulation,
1299    sample_count: usize,
1300) -> Vec<PhasePatternComponent> {
1301    phases
1302        .iter()
1303        .enumerate()
1304        .map(|(phase_index, phase)| {
1305            let mut phase_y = vec![0.0; sample_count];
1306            let first = phase_offsets[phase_index];
1307            let last = phase_offsets[phase_index + 1];
1308            for (reflection, intensity) in intensities.iter().enumerate().take(last).skip(first) {
1309                let begin = accumulation.derivatives.local.offsets[reflection];
1310                let end = accumulation.derivatives.local.offsets[reflection + 1];
1311                let start = accumulation.derivatives.local.starts[reflection];
1312                for active in begin..end {
1313                    let sample = start + active - begin;
1314                    phase_y[sample] += intensity
1315                        * accumulation.derivatives.local.values
1316                            [active * accumulation.derivatives.local.parameter_count];
1317                }
1318            }
1319            PhasePatternComponent {
1320                phase_id: phase.phase_id.clone(),
1321                y: phase_y,
1322            }
1323        })
1324        .collect()
1325}
1326
1327/// Return deterministic positive starting intensities in phase order.
1328///
1329/// # Errors
1330///
1331/// Returns [`LeBailError`] for invalid input or option state.
1332pub fn initialize_lebail_intensities(
1333    input: &LeBailInput,
1334    options: &LeBailOptions,
1335) -> Result<Vec<f64>, LeBailError> {
1336    options.validate()?;
1337    let values = flatten_intensities(&input.phases);
1338    if values
1339        .iter()
1340        .any(|value| !value.is_finite() || *value < 0.0)
1341    {
1342        return Err(invalid_phase(
1343            "starting intensities must be non-negative and finite",
1344        ));
1345    }
1346    if values.iter().any(|value| *value > 0.0) {
1347        return Ok(values
1348            .into_iter()
1349            .map(|value| value.max(options.initial_intensity_floor))
1350            .collect());
1351    }
1352    let observed = input
1353        .pattern
1354        .observed_y
1355        .as_deref()
1356        .ok_or(LeBailError::MissingObservations)?;
1357    let weights = bin_integration_weights(&input.pattern.x_deg);
1358    let mut background_y = input.pattern.background_y.clone();
1359    if let Some(background) = &input.background {
1360        for (fixed, residual) in background_y.iter_mut().zip(
1361            background
1362                .calculate(&input.pattern.x_deg)
1363                .map_err(LeBailError::Background)?,
1364        ) {
1365            *fixed += residual;
1366        }
1367    }
1368    let area = observed
1369        .iter()
1370        .zip(&background_y)
1371        .zip(weights)
1372        .map(|((observed, background), width)| (observed - background).max(0.0) * width)
1373        .sum::<f64>();
1374    let starting = (area / count_as_f64(values.len().max(1))).max(options.initial_intensity_floor);
1375    Ok(vec![starting; values.len()])
1376}
1377
1378/// Perform one non-negative multiplicative redistribution step.
1379///
1380/// # Errors
1381///
1382/// Returns [`LeBailError`] for shape, observation, or finite-state failures.
1383pub fn extract_lebail_intensities(
1384    pattern: &PatternRecord,
1385    calculation: &LeBailCalculation,
1386    current: &[f64],
1387    options: &LeBailOptions,
1388    preserve_unobserved: &[bool],
1389) -> Result<IntensityExtractionResult, LeBailError> {
1390    options.validate()?;
1391    let observed = pattern
1392        .observed_y
1393        .as_deref()
1394        .ok_or(LeBailError::MissingObservations)?;
1395    let reflection_count = calculation.accumulation.derivatives.local.peak_count();
1396    if current.len() != reflection_count
1397        || current
1398            .iter()
1399            .any(|value| !value.is_finite() || *value < 0.0)
1400    {
1401        return Err(LeBailError::IntensityShapeMismatch);
1402    }
1403    if preserve_unobserved.len() != reflection_count {
1404        return Err(LeBailError::PreserveMaskLengthMismatch);
1405    }
1406    let included = pattern
1407        .mask
1408        .clone()
1409        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1410    let ratio = observed
1411        .iter()
1412        .zip(&calculation.background_y)
1413        .zip(&calculation.profile_y)
1414        .zip(&included)
1415        .map(|(((observed, background), calculated), included)| {
1416            if *included && *calculated > options.minimum_calculated {
1417                (observed - background).max(0.0) / calculated
1418            } else {
1419                0.0
1420            }
1421        })
1422        .collect::<Vec<_>>();
1423    let mut weights = bin_integration_weights(&pattern.x_deg);
1424    if options.use_uncertainty
1425        && let Some(uncertainty) = &pattern.uncertainty
1426    {
1427        for (weight, uncertainty) in weights.iter_mut().zip(uncertainty) {
1428            *weight /= uncertainty * uncertainty;
1429        }
1430    }
1431    for (weight, included) in weights.iter_mut().zip(&included) {
1432        if !included {
1433            *weight = 0.0;
1434        }
1435    }
1436    let local = &calculation.accumulation.derivatives.local;
1437    let mut updated = vec![0.0; reflection_count];
1438    let mut unobserved_reflections = Vec::new();
1439    for reflection in 0..reflection_count {
1440        let begin = local.offsets[reflection];
1441        let end = local.offsets[reflection + 1];
1442        let start = local.starts[reflection];
1443        let mut denominator = 0.0;
1444        let mut numerator = 0.0;
1445        for active in begin..end {
1446            let sample = start + active - begin;
1447            let profile = local.values[active * local.parameter_count];
1448            let weighted_profile = weights[sample] * profile;
1449            denominator += weighted_profile;
1450            numerator += weighted_profile * ratio[sample];
1451        }
1452        if denominator <= 0.0 {
1453            unobserved_reflections.push(calculation.reflection_keys[reflection].clone());
1454            if preserve_unobserved[reflection] {
1455                updated[reflection] = current[reflection];
1456            }
1457            continue;
1458        }
1459        let raw = (current[reflection] * numerator / denominator).max(0.0);
1460        updated[reflection] =
1461            current[reflection] + options.redistribution_damping * (raw - current[reflection]);
1462    }
1463    let maximum_relative_change = updated
1464        .iter()
1465        .zip(current)
1466        .map(|(updated, current)| {
1467            (updated - current).abs() / current.abs().max(options.initial_intensity_floor)
1468        })
1469        .fold(0.0_f64, f64::max);
1470    Ok(IntensityExtractionResult {
1471        intensities: updated,
1472        maximum_relative_change,
1473        unobserved_reflections,
1474    })
1475}
1476
1477/// Run fixed-reflection Le Bail extraction with a workflow-owned runtime.
1478///
1479/// # Errors
1480///
1481/// Returns [`LeBailError`] for invalid state, runtime construction, profile
1482/// evaluation, metrics, or checkpoint delivery.
1483pub fn refine_lebail(
1484    input: &LeBailInput,
1485    options: &LeBailOptions,
1486    checkpoint: Option<&LeBailCheckpoint>,
1487) -> Result<LeBailResult, LeBailError> {
1488    let evaluations_per_iteration = options
1489        .max_profile_backtracks
1490        .checked_add(2)
1491        .ok_or(LeBailError::SizeOverflow)?;
1492    let max_evaluations = options
1493        .max_iterations
1494        .checked_mul(evaluations_per_iteration)
1495        .and_then(|value| value.checked_add(1))
1496        .ok_or(LeBailError::SizeOverflow)?;
1497    let limits = RefinementLimits::new(options.max_iterations, max_evaluations, None, 1)
1498        .map_err(LeBailError::Runtime)?;
1499    let mut runtime = RefinementRuntime::new(limits, None).map_err(LeBailError::Runtime)?;
1500    refine_lebail_with_runtime(input, options, checkpoint, &mut runtime)
1501}
1502
1503/// Advance exactly one accepted Le Bail iteration for custom orchestration.
1504///
1505/// Pass the returned checkpoint to the next call. A cancellation-aware host
1506/// that needs to stop before the iteration should use
1507/// [`refine_lebail_with_runtime`] with a one-iteration runtime budget.
1508///
1509/// # Errors
1510///
1511/// Returns [`LeBailError`] for invalid input, options, checkpoint, or numerical
1512/// evaluation state.
1513pub fn iterate_lebail_once(
1514    input: &LeBailInput,
1515    options: &LeBailOptions,
1516    checkpoint: Option<&LeBailCheckpoint>,
1517) -> Result<LeBailResult, LeBailError> {
1518    let completed = checkpoint.map_or(0, |value| value.completed_iterations);
1519    let iteration = completed.checked_add(1).ok_or(LeBailError::SizeOverflow)?;
1520    let mut selected = options.clone();
1521    selected.min_iterations = iteration;
1522    selected.max_iterations = iteration;
1523    selected.validate()?;
1524    refine_lebail(input, &selected, checkpoint)
1525}
1526
1527/// Run fixed-reflection extraction with host-owned cancellation/events/checkpoints.
1528///
1529/// The runtime should be fresh for a new run. A continuation restores its
1530/// accepted counter from the supplied checkpoint before numerical work begins.
1531///
1532/// # Errors
1533///
1534/// Returns [`LeBailError`] for invalid input/checkpoint state, non-normal
1535/// runtime failures, or numerical evaluation failures.
1536pub fn refine_lebail_with_runtime(
1537    input: &LeBailInput,
1538    options: &LeBailOptions,
1539    checkpoint: Option<&LeBailCheckpoint>,
1540    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1541) -> Result<LeBailResult, LeBailError> {
1542    options.validate()?;
1543    let mut state = restore_state(input, options, checkpoint)?;
1544    if let Some(checkpoint) = checkpoint {
1545        runtime
1546            .resume_accepted(checkpoint.completed_iterations)
1547            .map_err(LeBailError::Runtime)?;
1548    }
1549    runtime
1550        .emit(
1551            RefinementEventKind::Start,
1552            "lebail",
1553            "Le Bail extraction started",
1554            Vec::new(),
1555        )
1556        .map_err(LeBailError::Runtime)?;
1557    state.calculation = Some(calculate_lebail_pattern_with_background(
1558        &input.pattern,
1559        state.instrument,
1560        &state.phases,
1561        state.background.as_ref(),
1562        options.support_fwhm,
1563        &options.execution,
1564    )?);
1565    let termination = run_lebail_iterations(input, options, &mut state, runtime)?;
1566    finish_result(
1567        input,
1568        options,
1569        state.phases,
1570        state.instrument,
1571        state.background,
1572        &state.intensities,
1573        state.parameters,
1574        state.history,
1575        state.previous_rwp,
1576        state.calculation.ok_or(LeBailError::InternalInvariant)?,
1577        termination,
1578        runtime,
1579    )
1580}
1581
1582fn run_lebail_iterations(
1583    input: &LeBailInput,
1584    options: &LeBailOptions,
1585    state: &mut RestoredLeBailState,
1586    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1587) -> Result<TerminationReason, LeBailError> {
1588    if let Err(error) = runtime.begin_evaluation() {
1589        return stop_reason_or_error(error);
1590    }
1591    for iteration in state.first_iteration..=options.max_iterations {
1592        if let Err(error) = runtime.begin_iteration(iteration) {
1593            return stop_reason_or_error(error);
1594        }
1595        if let Err(error) = runtime.begin_evaluation() {
1596            return stop_reason_or_error(error);
1597        }
1598        let candidate = match evaluate_lebail_iteration(input, options, state, runtime) {
1599            Ok(candidate) => candidate,
1600            Err(LeBailError::Runtime(error)) if normal_stop_reason(&error).is_some() => {
1601                return stop_reason_or_error(error);
1602            }
1603            Err(error) => return Err(error),
1604        };
1605        state.history.push(LeBailIterationRecord {
1606            iteration,
1607            rp: candidate.metrics.rp,
1608            rwp: candidate.metrics.rwp,
1609            chi_square: candidate.metrics.chi_square,
1610            reduced_chi_square: candidate.metrics.reduced_chi_square,
1611            maximum_relative_intensity_change: candidate.extraction.maximum_relative_change,
1612            scaled_profile_step_norm: candidate.profile_step_norm,
1613            parameter_changes: candidate.parameter_changes,
1614            warnings: candidate.warnings,
1615        });
1616        state.instrument = candidate.instrument;
1617        state.background = candidate.background;
1618        state.phases = candidate.phases;
1619        state.parameters = candidate.parameters;
1620        state.intensities = candidate.extraction.intensities;
1621        state.calculation = Some(candidate.calculation);
1622        accept_lebail_iteration(runtime, state, &candidate.metrics)?;
1623        if iteration >= options.min_iterations
1624            && candidate.extraction.maximum_relative_change < options.intensity_tolerance
1625            && (state.previous_rwp - candidate.metrics.rwp).abs() < options.rwp_tolerance
1626        {
1627            return Ok(TerminationReason::Converged);
1628        }
1629        state.previous_rwp = candidate.metrics.rwp;
1630    }
1631    Ok(TerminationReason::MaxIterations)
1632}
1633
1634struct EvaluatedLeBailIteration {
1635    extraction: IntensityExtractionResult,
1636    phases: Vec<LeBailPhase>,
1637    calculation: LeBailCalculation,
1638    metrics: ResidualEvaluation,
1639    warnings: Vec<String>,
1640    instrument: ConstantWavelengthInstrument,
1641    background: Option<BackgroundModel>,
1642    parameters: Option<ParameterSet>,
1643    profile_step_norm: f64,
1644    parameter_changes: Vec<ParameterChange>,
1645}
1646
1647fn evaluate_lebail_iteration(
1648    input: &LeBailInput,
1649    options: &LeBailOptions,
1650    state: &RestoredLeBailState,
1651    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1652) -> Result<EvaluatedLeBailIteration, LeBailError> {
1653    let mut extraction = extract_lebail_intensities(
1654        &input.pattern,
1655        state.calculation()?,
1656        &state.intensities,
1657        options,
1658        &flatten_preserve_mask(&state.phases),
1659    )?;
1660    let phases = replace_flat_intensities(&state.phases, &extraction.intensities)?;
1661    let calculation = calculate_lebail_pattern_with_background(
1662        &input.pattern,
1663        state.instrument,
1664        &phases,
1665        state.background.as_ref(),
1666        options.support_fwhm,
1667        &options.execution,
1668    )?;
1669    let (background, parameters) = fit_linear_background(
1670        &input.pattern,
1671        &calculation.profile_y,
1672        state.background.as_ref(),
1673        state.parameters.as_ref(),
1674        options.use_uncertainty,
1675    )?;
1676    let calculation = calculate_lebail_pattern_with_background(
1677        &input.pattern,
1678        state.instrument,
1679        &phases,
1680        background.as_ref(),
1681        options.support_fwhm,
1682        &options.execution,
1683    )?;
1684    let profile = profile_update(
1685        &input.pattern,
1686        state.instrument,
1687        phases,
1688        calculation,
1689        parameters.as_ref(),
1690        background,
1691        &input.constraints,
1692        options,
1693        runtime,
1694    )?;
1695    extraction.intensities = flatten_intensities(&profile.phases);
1696    let parameter_count = free_parameter_count(profile.parameters.as_ref(), &input.constraints)?;
1697    let metrics = evaluate_residuals(
1698        &input.pattern,
1699        &profile.calculation.y,
1700        ResidualOptions {
1701            use_uncertainty: options.use_uncertainty,
1702            parameter_count,
1703        },
1704    )
1705    .map_err(LeBailError::Residual)?;
1706    let warnings = if extraction.unobserved_reflections.is_empty() {
1707        Vec::new()
1708    } else {
1709        vec![format!(
1710            "{} reflections have no included support",
1711            extraction.unobserved_reflections.len()
1712        )]
1713    };
1714    Ok(EvaluatedLeBailIteration {
1715        extraction,
1716        phases: profile.phases,
1717        calculation: profile.calculation,
1718        metrics,
1719        warnings: [warnings, profile.warnings].concat(),
1720        instrument: profile.instrument,
1721        background: profile.background,
1722        parameters: profile.parameters,
1723        profile_step_norm: profile.step_norm,
1724        parameter_changes: profile.parameter_changes,
1725    })
1726}
1727
1728fn accept_lebail_iteration(
1729    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1730    state: &RestoredLeBailState,
1731    metrics: &ResidualEvaluation,
1732) -> Result<(), LeBailError> {
1733    let checkpoint = LeBailCheckpoint {
1734        completed_iterations: state.history.len(),
1735        phases: state.phases.clone(),
1736        instrument: state.instrument,
1737        background: state.background.clone(),
1738        intensities: state.intensities.clone(),
1739        parameters: state.parameters.clone(),
1740        previous_rwp: metrics.rwp,
1741        history: state.history.clone(),
1742    };
1743    runtime
1744        .accept_step(Some(&checkpoint))
1745        .map_err(LeBailError::Runtime)?;
1746    runtime
1747        .emit(
1748            RefinementEventKind::Iteration,
1749            "lebail_iteration",
1750            "Le Bail iteration accepted",
1751            vec![
1752                ("rwp".to_owned(), DiagnosticValue::Float(metrics.rwp)),
1753                (
1754                    "maximum_relative_intensity_change".to_owned(),
1755                    DiagnosticValue::Float(
1756                        state
1757                            .history
1758                            .last()
1759                            .ok_or(LeBailError::InternalInvariant)?
1760                            .maximum_relative_intensity_change,
1761                    ),
1762                ),
1763            ],
1764        )
1765        .map_err(LeBailError::Runtime)?;
1766    Ok(())
1767}
1768
1769#[allow(clippy::too_many_arguments)]
1770fn finish_result(
1771    input: &LeBailInput,
1772    options: &LeBailOptions,
1773    phases: Vec<LeBailPhase>,
1774    instrument: ConstantWavelengthInstrument,
1775    background: Option<BackgroundModel>,
1776    intensities: &[f64],
1777    parameters: Option<ParameterSet>,
1778    history: Vec<LeBailIterationRecord>,
1779    previous_rwp: f64,
1780    calculation: LeBailCalculation,
1781    termination: TerminationReason,
1782    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1783) -> Result<LeBailResult, LeBailError> {
1784    let metrics = evaluate_residuals(
1785        &input.pattern,
1786        &calculation.y,
1787        ResidualOptions {
1788            use_uncertainty: options.use_uncertainty,
1789            parameter_count: free_parameter_count(parameters.as_ref(), &input.constraints)?,
1790        },
1791    )
1792    .map_err(LeBailError::Residual)?;
1793    let checkpoint = LeBailCheckpoint {
1794        completed_iterations: history.len(),
1795        phases: phases.clone(),
1796        instrument,
1797        background: background.clone(),
1798        intensities: intensities.to_owned(),
1799        parameters: parameters.clone(),
1800        previous_rwp: if termination == TerminationReason::Cancelled {
1801            previous_rwp
1802        } else {
1803            metrics.rwp
1804        },
1805        history: history.clone(),
1806    };
1807    checkpoint.validate()?;
1808    let labeled = calculation
1809        .reflection_keys
1810        .iter()
1811        .zip(intensities)
1812        .map(
1813            |((phase_id, reflection_id), intensity)| ReflectionIntensity {
1814                phase_id: phase_id.clone(),
1815                reflection_id: reflection_id.clone(),
1816                integrated_intensity: *intensity,
1817            },
1818        )
1819        .collect();
1820    let rank_deficient_groups = if options.diagnose_rank_deficiency {
1821        rank_deficient_groups(&calculation, options.unresolved_correlation)
1822    } else {
1823        Vec::new()
1824    };
1825    let covariance = covariance(
1826        &input.pattern,
1827        &calculation,
1828        instrument,
1829        background.as_ref(),
1830        &phases,
1831        parameters.as_ref(),
1832        &input.constraints,
1833        options.use_uncertainty,
1834        metrics.reduced_chi_square,
1835    )?;
1836    runtime
1837        .emit(
1838            RefinementEventKind::Termination,
1839            "lebail",
1840            "Le Bail extraction terminated",
1841            vec![(
1842                "termination_reason".to_owned(),
1843                DiagnosticValue::String(termination.as_str().to_owned()),
1844            )],
1845        )
1846        .map_err(LeBailError::Runtime)?;
1847    Ok(LeBailResult {
1848        calculation,
1849        phases,
1850        instrument,
1851        background,
1852        intensities: labeled,
1853        metrics,
1854        history,
1855        termination_reason: termination,
1856        rank_deficient_groups,
1857        parameters,
1858        covariance,
1859        checkpoint,
1860    })
1861}
1862
1863struct RestoredLeBailState {
1864    phases: Vec<LeBailPhase>,
1865    instrument: ConstantWavelengthInstrument,
1866    background: Option<BackgroundModel>,
1867    intensities: Vec<f64>,
1868    history: Vec<LeBailIterationRecord>,
1869    parameters: Option<ParameterSet>,
1870    previous_rwp: f64,
1871    first_iteration: usize,
1872    calculation: Option<LeBailCalculation>,
1873}
1874
1875impl RestoredLeBailState {
1876    fn calculation(&self) -> Result<&LeBailCalculation, LeBailError> {
1877        self.calculation
1878            .as_ref()
1879            .ok_or(LeBailError::InternalInvariant)
1880    }
1881}
1882
1883fn restore_state(
1884    input: &LeBailInput,
1885    options: &LeBailOptions,
1886    checkpoint: Option<&LeBailCheckpoint>,
1887) -> Result<RestoredLeBailState, LeBailError> {
1888    let Some(checkpoint) = checkpoint else {
1889        let intensities = initialize_lebail_intensities(input, options)?;
1890        let phases = replace_flat_intensities(&input.phases, &intensities)?;
1891        return Ok(RestoredLeBailState {
1892            phases,
1893            instrument: input.instrument,
1894            background: input.background.clone(),
1895            intensities,
1896            history: Vec::new(),
1897            parameters: input.parameters.clone(),
1898            previous_rwp: f64::INFINITY,
1899            first_iteration: 1,
1900            calculation: None,
1901        });
1902    };
1903    checkpoint.validate()?;
1904    if checkpoint.completed_iterations >= options.max_iterations {
1905        return Err(LeBailError::InvalidCheckpoint {
1906            message: "checkpoint already reached the configured maximum iteration".to_owned(),
1907        });
1908    }
1909    if !phases_restart_compatible(&input.phases, &checkpoint.phases) {
1910        return Err(LeBailError::InvalidCheckpoint {
1911            message: "checkpoint phase/reflection domain does not match the input".to_owned(),
1912        });
1913    }
1914    if !backgrounds_restart_compatible(input.background.as_ref(), checkpoint.background.as_ref()) {
1915        return Err(LeBailError::InvalidCheckpoint {
1916            message: "checkpoint background does not match the input".to_owned(),
1917        });
1918    }
1919    let input_parameter_keys = input.parameters.as_ref().map(parameter_keys);
1920    let checkpoint_parameter_keys = checkpoint.parameters.as_ref().map(parameter_keys);
1921    if input_parameter_keys != checkpoint_parameter_keys {
1922        return Err(LeBailError::InvalidCheckpoint {
1923            message: "checkpoint parameter identities do not match the input".to_owned(),
1924        });
1925    }
1926    Ok(RestoredLeBailState {
1927        phases: checkpoint.phases.clone(),
1928        instrument: checkpoint.instrument,
1929        background: checkpoint.background.clone(),
1930        intensities: checkpoint.intensities.clone(),
1931        history: checkpoint.history.clone(),
1932        parameters: checkpoint.parameters.clone(),
1933        previous_rwp: checkpoint.previous_rwp,
1934        first_iteration: checkpoint.completed_iterations + 1,
1935        calculation: None,
1936    })
1937}
1938
1939struct ProfileUpdate {
1940    instrument: ConstantWavelengthInstrument,
1941    phases: Vec<LeBailPhase>,
1942    background: Option<BackgroundModel>,
1943    calculation: LeBailCalculation,
1944    parameters: Option<ParameterSet>,
1945    step_norm: f64,
1946    parameter_changes: Vec<ParameterChange>,
1947    warnings: Vec<String>,
1948}
1949
1950fn fit_linear_background(
1951    pattern: &PatternRecord,
1952    profile_y: &[f64],
1953    background: Option<&BackgroundModel>,
1954    parameters: Option<&ParameterSet>,
1955    use_uncertainty: bool,
1956) -> Result<(Option<BackgroundModel>, Option<ParameterSet>), LeBailError> {
1957    let Some(background) = background else {
1958        return Ok((None, parameters.cloned()));
1959    };
1960    if !background.basis_is_invariant() {
1961        return Ok((Some(background.clone()), parameters.cloned()));
1962    }
1963    let observed = pattern
1964        .observed_y
1965        .as_deref()
1966        .ok_or(LeBailError::MissingObservations)?;
1967    let basis = background
1968        .basis(&pattern.x_deg)
1969        .map_err(LeBailError::Background)?;
1970    let included = pattern
1971        .mask
1972        .clone()
1973        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1974    let row_count = included.iter().filter(|value| **value).count();
1975    if row_count < basis.columns || profile_y.len() != pattern.sample_count() {
1976        return Err(LeBailError::LinearSolve);
1977    }
1978    let mut design = Vec::with_capacity(row_count * basis.columns);
1979    let mut target = Vec::with_capacity(row_count);
1980    for sample in 0..pattern.sample_count() {
1981        if !included[sample] {
1982            continue;
1983        }
1984        let sigma = if use_uncertainty {
1985            pattern
1986                .uncertainty
1987                .as_ref()
1988                .map_or(1.0, |values| values[sample])
1989        } else {
1990            1.0
1991        };
1992        let row = basis.row(sample).ok_or(LeBailError::InternalInvariant)?;
1993        design.extend(row.iter().map(|value| value / sigma));
1994        target.push((observed[sample] - pattern.background_y[sample] - profile_y[sample]) / sigma);
1995    }
1996    let matrix = DMatrix::from_row_slice(row_count, basis.columns, &design);
1997    let target = DVector::from_vec(target);
1998    let coefficients = matrix
1999        .svd(true, true)
2000        .solve(&target, 1.0e-12)
2001        .map_err(|_| LeBailError::LinearSolve)?;
2002    if coefficients.iter().any(|value| !value.is_finite()) {
2003        return Err(LeBailError::LinearSolve);
2004    }
2005    let updated = background
2006        .replace_coefficients(coefficients.as_slice())
2007        .map_err(LeBailError::Background)?;
2008    let updated_parameters = if let Some(parameters) = parameters {
2009        let values = updated
2010            .parameter_names()
2011            .iter()
2012            .zip(updated.coefficients())
2013            .map(|(name, value)| {
2014                Ok((
2015                    lebail_background_parameter_key(updated.background_id(), name)?,
2016                    value,
2017                ))
2018            })
2019            .collect::<Result<BTreeMap<_, _>, LeBailError>>()?;
2020        Some(
2021            parameters
2022                .replace_values(&values)
2023                .map_err(LeBailError::Parameter)?,
2024        )
2025    } else {
2026        None
2027    };
2028    Ok((Some(updated), updated_parameters))
2029}
2030
2031#[allow(clippy::too_many_arguments)]
2032// The linearization, bounded solve, and backtracking order intentionally stay
2033// adjacent so this numerical state transition remains auditable against the
2034// independent Python oracle.
2035#[allow(clippy::too_many_lines)]
2036fn profile_update(
2037    pattern: &PatternRecord,
2038    instrument: ConstantWavelengthInstrument,
2039    phases: Vec<LeBailPhase>,
2040    calculation: LeBailCalculation,
2041    parameters: Option<&ParameterSet>,
2042    background: Option<BackgroundModel>,
2043    constraints: &[Constraint],
2044    options: &LeBailOptions,
2045    runtime: &mut RefinementRuntime<LeBailCheckpoint>,
2046) -> Result<ProfileUpdate, LeBailError> {
2047    let Some(parameters) = parameters else {
2048        return Ok(ProfileUpdate {
2049            instrument,
2050            phases,
2051            background,
2052            calculation,
2053            parameters: None,
2054            step_norm: 0.0,
2055            parameter_changes: Vec::new(),
2056            warnings: Vec::new(),
2057        });
2058    };
2059    let domain_values =
2060        domain_parameter_values(instrument, &phases, background.as_ref(), parameters)?;
2061    let current = parameters
2062        .replace_values(&domain_values)
2063        .map_err(LeBailError::Parameter)?;
2064    let transform = ConstraintTransform::new(current.clone(), constraints.to_vec())
2065        .map_err(LeBailError::Constraint)?;
2066    if transform.free_keys().is_empty() {
2067        return Ok(ProfileUpdate {
2068            instrument,
2069            phases,
2070            background,
2071            calculation,
2072            parameters: Some(current),
2073            step_norm: 0.0,
2074            parameter_changes: Vec::new(),
2075            warnings: Vec::new(),
2076        });
2077    }
2078    let physical = parameter_columns(
2079        &calculation,
2080        &current,
2081        instrument,
2082        &phases,
2083        background.as_ref(),
2084        &pattern.x_deg,
2085    )?;
2086    let derivative = transform
2087        .derivative_matrix()
2088        .map_err(LeBailError::Constraint)?;
2089    let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
2090    let jacobian = physical * chain;
2091    let observed = pattern
2092        .observed_y
2093        .as_deref()
2094        .ok_or(LeBailError::MissingObservations)?;
2095    let included = pattern
2096        .mask
2097        .clone()
2098        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
2099    let selected_count = included.iter().filter(|value| **value).count();
2100    let free_count = transform.free_keys().len();
2101    let mut selected_jacobian = DMatrix::zeros(selected_count, free_count);
2102    let mut selected_residual = DVector::zeros(selected_count);
2103    let mut selected_row = 0;
2104    for sample in 0..pattern.sample_count() {
2105        if !included[sample] {
2106            continue;
2107        }
2108        let weight = if options.use_uncertainty {
2109            pattern
2110                .uncertainty
2111                .as_ref()
2112                .map_or(1.0, |values| values[sample].recip())
2113        } else {
2114            1.0
2115        };
2116        selected_residual[selected_row] = (observed[sample] - calculation.y[sample]) * weight;
2117        for column in 0..free_count {
2118            selected_jacobian[(selected_row, column)] = jacobian[(sample, column)] * weight;
2119        }
2120        selected_row += 1;
2121    }
2122    let normal = selected_jacobian.transpose() * &selected_jacobian;
2123    let mut warnings = Vec::new();
2124    if matrix_rank(&normal) != free_count {
2125        warnings.push("profile Jacobian is rank deficient".to_owned());
2126    }
2127    if current
2128        .specs()
2129        .iter()
2130        .any(|spec| spec.key().module() == "phase" && spec.key().name() == "scale")
2131    {
2132        warnings.push(
2133            "phase scale is not identifiable independently of extracted Le Bail intensities"
2134                .to_owned(),
2135        );
2136    }
2137    let base = transform.pack().map_err(LeBailError::Constraint)?;
2138    let mut lower = vec![-options.max_scaled_parameter_step; free_count];
2139    let mut upper = vec![options.max_scaled_parameter_step; free_count];
2140    for (index, key) in transform.free_keys().iter().enumerate() {
2141        let spec = current.spec(key).ok_or(LeBailError::InternalInvariant)?;
2142        lower[index] = lower[index].max(spec.bounds().lower() / spec.scale() - base[index]);
2143        upper[index] = upper[index].min(spec.bounds().upper() / spec.scale() - base[index]);
2144    }
2145    let rhs = selected_jacobian.transpose() * &selected_residual;
2146    let mut regularized = normal;
2147    for index in 0..free_count {
2148        regularized[(index, index)] += options.profile_damping;
2149    }
2150    let mut step = if let Some(solution) = regularized.lu().solve(&rhs) {
2151        solution
2152    } else {
2153        warnings.push("profile normal equations used least-squares fallback".to_owned());
2154        selected_jacobian
2155            .clone()
2156            .svd(true, true)
2157            .solve(&selected_residual, f64::EPSILON)
2158            .map_err(|_| LeBailError::LinearSolve)?
2159    };
2160    for index in 0..free_count {
2161        step[index] = step[index].clamp(lower[index], upper[index]);
2162    }
2163    let baseline = evaluate_residuals(
2164        pattern,
2165        &calculation.y,
2166        ResidualOptions {
2167            use_uncertainty: options.use_uncertainty,
2168            parameter_count: free_count,
2169        },
2170    )
2171    .map_err(LeBailError::Residual)?;
2172    let mut factor = 1.0;
2173    for _ in 0..=options.max_profile_backtracks {
2174        let trial = base
2175            .iter()
2176            .zip(step.iter())
2177            .map(|(base, step)| base + factor * step)
2178            .collect::<Vec<_>>();
2179        let Ok(values) = transform.unpack(&trial, true) else {
2180            factor *= 0.5;
2181            continue;
2182        };
2183        let Ok((candidate_instrument, candidate_phases, candidate_background)) =
2184            apply_parameter_values(instrument, &phases, background.as_ref(), &values)
2185        else {
2186            factor *= 0.5;
2187            continue;
2188        };
2189        runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
2190        let Ok(candidate_calculation) = calculate_lebail_pattern_with_background(
2191            pattern,
2192            candidate_instrument,
2193            &candidate_phases,
2194            candidate_background.as_ref(),
2195            options.support_fwhm,
2196            &options.execution,
2197        ) else {
2198            factor *= 0.5;
2199            continue;
2200        };
2201        let candidate_metrics = evaluate_residuals(
2202            pattern,
2203            &candidate_calculation.y,
2204            ResidualOptions {
2205                use_uncertainty: options.use_uncertainty,
2206                parameter_count: free_count,
2207            },
2208        )
2209        .map_err(LeBailError::Residual)?;
2210        if candidate_metrics.chi_square < baseline.chi_square {
2211            let candidate_parameters = current
2212                .replace_values(&values)
2213                .map_err(LeBailError::Parameter)?;
2214            let (candidate_phases, domain_warnings, topology_changed) =
2215                regenerate_accepted_domains(candidate_phases)?;
2216            let candidate_calculation = if topology_changed {
2217                runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
2218                calculate_lebail_pattern_with_background(
2219                    pattern,
2220                    candidate_instrument,
2221                    &candidate_phases,
2222                    candidate_background.as_ref(),
2223                    options.support_fwhm,
2224                    &options.execution,
2225                )?
2226            } else {
2227                candidate_calculation
2228            };
2229            let parameter_changes = current
2230                .specs()
2231                .iter()
2232                .filter_map(|spec| {
2233                    let after = candidate_parameters.spec(spec.key())?.value();
2234                    (after.to_bits() != spec.value().to_bits()).then(|| ParameterChange {
2235                        key: spec.key().clone(),
2236                        before: spec.value(),
2237                        after,
2238                        scaled_change: (after - spec.value()) / spec.scale(),
2239                    })
2240                })
2241                .collect();
2242            return Ok(ProfileUpdate {
2243                instrument: candidate_instrument,
2244                phases: candidate_phases,
2245                background: candidate_background,
2246                calculation: candidate_calculation,
2247                parameters: Some(candidate_parameters),
2248                step_norm: factor * step.norm(),
2249                parameter_changes,
2250                warnings: [warnings, domain_warnings].concat(),
2251            });
2252        }
2253        factor *= 0.5;
2254    }
2255    warnings.push("profile step rejected by backtracking".to_owned());
2256    Ok(ProfileUpdate {
2257        instrument,
2258        phases,
2259        background,
2260        calculation,
2261        parameters: Some(current),
2262        step_norm: 0.0,
2263        parameter_changes: Vec::new(),
2264        warnings,
2265    })
2266}
2267
2268fn domain_parameter_values(
2269    instrument: ConstantWavelengthInstrument,
2270    phases: &[LeBailPhase],
2271    background: Option<&BackgroundModel>,
2272    parameters: &ParameterSet,
2273) -> Result<BTreeMap<ParameterKey, f64>, LeBailError> {
2274    let mut values = BTreeMap::new();
2275    for spec in parameters.specs() {
2276        let key = spec.key();
2277        let value = if key.module() == "instrument" && key.owner_id() == "cw" {
2278            instrument_parameter(instrument, key.name())
2279        } else if key.module() == "phase" && key.name() == "scale" {
2280            phases
2281                .iter()
2282                .find(|phase| phase.phase_id() == key.owner_id())
2283                .map(LeBailPhase::scale)
2284        } else if key.module() == "background" {
2285            background.and_then(|background| {
2286                if background.background_id() != key.owner_id() {
2287                    return None;
2288                }
2289                background
2290                    .parameter_names()
2291                    .iter()
2292                    .position(|name| name == key.name())
2293                    .and_then(|index| background.coefficients().get(index).copied())
2294            })
2295        } else if key.module() == "lattice" {
2296            phases
2297                .iter()
2298                .find(|phase| phase.phase_id() == key.owner_id())
2299                .and_then(|phase| {
2300                    let cell = phase.cell?;
2301                    let parameterization = phase.reflection_domain.as_ref()?.parameterization();
2302                    let index = parameterization
2303                        .parameter_names()
2304                        .iter()
2305                        .position(|name| name == key.name())?;
2306                    parameterization
2307                        .values_from_cell(cell)
2308                        .ok()?
2309                        .get(index)
2310                        .copied()
2311                })
2312        } else if key.module() == "reflection" && key.name() == "two_theta_deg" {
2313            phases.iter().find_map(|phase| {
2314                phase
2315                    .reflection_ids
2316                    .iter()
2317                    .position(|reflection_id| {
2318                        format!("{}/{}", phase.phase_id(), reflection_id) == key.owner_id()
2319                    })
2320                    .map(|index| phase.two_theta_deg[index])
2321            })
2322        } else {
2323            None
2324        }
2325        .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2326        if !spec.bounds().contains(value) {
2327            return Err(LeBailError::ParameterDomainOutsideBounds { label: key.label() });
2328        }
2329        values.insert(key.clone(), value);
2330    }
2331    Ok(values)
2332}
2333
2334fn parameter_columns(
2335    calculation: &LeBailCalculation,
2336    parameters: &ParameterSet,
2337    instrument: ConstantWavelengthInstrument,
2338    phases: &[LeBailPhase],
2339    background: Option<&BackgroundModel>,
2340    x_deg: &[f64],
2341) -> Result<DMatrix<f64>, LeBailError> {
2342    let samples = calculation.y.len();
2343    let mut matrix = DMatrix::zeros(samples, parameters.specs().len());
2344    let global = calculation
2345        .accumulation
2346        .derivatives
2347        .global
2348        .as_ref()
2349        .ok_or(LeBailError::InternalInvariant)?;
2350    for (column, spec) in parameters.specs().iter().enumerate() {
2351        let key = spec.key();
2352        if key.module() == "instrument" {
2353            let row = INSTRUMENT_PARAMETER_NAMES
2354                .iter()
2355                .position(|name| *name == key.name())
2356                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2357            for sample in 0..samples {
2358                matrix[(sample, column)] = global.values[row * samples + sample];
2359            }
2360        } else if key.module() == "phase" {
2361            let phase = phases
2362                .iter()
2363                .position(|phase| phase.phase_id() == key.owner_id())
2364                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2365            let row = 5 + phase;
2366            for sample in 0..samples {
2367                matrix[(sample, column)] = global.values[row * samples + sample];
2368            }
2369        } else if key.module() == "reflection" {
2370            let reflection = calculation
2371                .reflection_keys
2372                .iter()
2373                .position(|(phase_id, reflection_id)| {
2374                    format!("{phase_id}/{reflection_id}") == key.owner_id()
2375                })
2376                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2377            let local = &calculation.accumulation.derivatives.local;
2378            let begin = local.offsets[reflection];
2379            let end = local.offsets[reflection + 1];
2380            let start = local.starts[reflection];
2381            for active in begin..end {
2382                matrix[(start + active - begin, column)] =
2383                    local.values[active * local.parameter_count + 1];
2384            }
2385        } else if key.module() == "lattice" {
2386            let phase = phases
2387                .iter()
2388                .find(|phase| phase.phase_id() == key.owner_id())
2389                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2390            let cell = phase
2391                .cell
2392                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2393            let domain = phase
2394                .reflection_domain
2395                .as_ref()
2396                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2397            let geometry = cw_lattice_geometry(
2398                domain.parameterization(),
2399                cell,
2400                &phase.hkl,
2401                instrument.wavelength_angstrom,
2402            )
2403            .map_err(LeBailError::Lattice)?;
2404            let parameter = geometry
2405                .parameter_names
2406                .iter()
2407                .position(|name| name == key.name())
2408                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2409            let local = &calculation.accumulation.derivatives.local;
2410            for (phase_reflection, reflection_id) in phase.reflection_ids.iter().enumerate() {
2411                let reflection = calculation
2412                    .reflection_keys
2413                    .iter()
2414                    .position(|(phase_id, candidate_id)| {
2415                        phase_id == phase.phase_id() && candidate_id == reflection_id
2416                    })
2417                    .ok_or(LeBailError::InternalInvariant)?;
2418                let derivative = geometry.d_two_theta_d_parameters
2419                    [phase_reflection * geometry.parameter_names.len() + parameter];
2420                let begin = local.offsets[reflection];
2421                let end = local.offsets[reflection + 1];
2422                let start = local.starts[reflection];
2423                for active in begin..end {
2424                    matrix[(start + active - begin, column)] +=
2425                        local.values[active * local.parameter_count + 1] * derivative;
2426                }
2427            }
2428        } else if key.module() == "background" {
2429            fill_background_parameter_column(&mut matrix, column, key, background, x_deg)?;
2430        } else {
2431            return Err(LeBailError::UnsupportedParameter { label: key.label() });
2432        }
2433    }
2434    Ok(matrix)
2435}
2436
2437fn fill_background_parameter_column(
2438    matrix: &mut DMatrix<f64>,
2439    column: usize,
2440    key: &ParameterKey,
2441    background: Option<&BackgroundModel>,
2442    x_deg: &[f64],
2443) -> Result<(), LeBailError> {
2444    let background = background
2445        .filter(|background| background.background_id() == key.owner_id())
2446        .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2447    let parameter = background
2448        .parameter_names()
2449        .iter()
2450        .position(|name| name == key.name())
2451        .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2452    let basis = background.basis(x_deg).map_err(LeBailError::Background)?;
2453    let values = basis
2454        .column(parameter)
2455        .ok_or(LeBailError::InternalInvariant)?;
2456    for sample in 0..matrix.nrows() {
2457        matrix[(sample, column)] = values[sample];
2458    }
2459    Ok(())
2460}
2461
2462fn apply_parameter_values(
2463    instrument: ConstantWavelengthInstrument,
2464    phases: &[LeBailPhase],
2465    background: Option<&BackgroundModel>,
2466    values: &BTreeMap<ParameterKey, f64>,
2467) -> Result<
2468    (
2469        ConstantWavelengthInstrument,
2470        Vec<LeBailPhase>,
2471        Option<BackgroundModel>,
2472    ),
2473    LeBailError,
2474> {
2475    let mut updated_instrument = instrument;
2476    for (key, value) in values {
2477        if key.module() == "instrument" {
2478            set_instrument_parameter(&mut updated_instrument, key.name(), *value)?;
2479        }
2480    }
2481    updated_instrument
2482        .validate()
2483        .map_err(|error| LeBailError::Profile {
2484            message: error.to_string(),
2485        })?;
2486    let mut updated_phases = Vec::with_capacity(phases.len());
2487    for phase in phases {
2488        let scale = values
2489            .get(&lebail_phase_scale_key(phase.phase_id())?)
2490            .copied()
2491            .unwrap_or(phase.scale());
2492        let mut positions = phase.two_theta_deg.clone();
2493        for (index, reflection_id) in phase.reflection_ids.iter().enumerate() {
2494            if let Some(value) = values.get(&lebail_reflection_position_key(
2495                phase.phase_id(),
2496                reflection_id,
2497            )?) {
2498                positions[index] = *value;
2499            }
2500        }
2501        let mut updated = phase.replace_scale_and_positions(scale, positions)?;
2502        let lattice_values = values
2503            .iter()
2504            .filter(|(key, _)| key.module() == "lattice" && key.owner_id() == phase.phase_id())
2505            .collect::<Vec<_>>();
2506        if !lattice_values.is_empty() {
2507            let cell = phase.cell.ok_or_else(|| {
2508                invalid_phase("lattice parameters require a bounded reflection domain")
2509            })?;
2510            let domain = phase.reflection_domain.as_ref().ok_or_else(|| {
2511                invalid_phase("lattice parameters require a bounded reflection domain")
2512            })?;
2513            let mut independent = domain
2514                .parameterization()
2515                .values_from_cell(cell)
2516                .map_err(LeBailError::Lattice)?;
2517            for (key, value) in lattice_values {
2518                let index = domain
2519                    .parameterization()
2520                    .parameter_names()
2521                    .iter()
2522                    .position(|name| name == key.name())
2523                    .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2524                independent[index] = *value;
2525            }
2526            let cell = domain
2527                .parameterization()
2528                .to_cell(&independent)
2529                .map_err(LeBailError::Lattice)?;
2530            updated =
2531                updated.replace_cell_geometry(cell, updated_instrument.wavelength_angstrom)?;
2532        }
2533        updated_phases.push(updated);
2534    }
2535    let updated_background = if let Some(background) = background {
2536        let names = background.parameter_names();
2537        let coefficients = names
2538            .iter()
2539            .zip(background.coefficients())
2540            .map(|(name, current)| {
2541                values
2542                    .get(&lebail_background_parameter_key(
2543                        background.background_id(),
2544                        name,
2545                    )?)
2546                    .copied()
2547                    .map_or(Ok(current), Ok)
2548            })
2549            .collect::<Result<Vec<_>, LeBailError>>()?;
2550        Some(
2551            background
2552                .replace_coefficients(&coefficients)
2553                .map_err(LeBailError::Background)?,
2554        )
2555    } else {
2556        None
2557    };
2558    Ok((updated_instrument, updated_phases, updated_background))
2559}
2560
2561fn regenerate_accepted_domains(
2562    phases: Vec<LeBailPhase>,
2563) -> Result<(Vec<LeBailPhase>, Vec<String>, bool), LeBailError> {
2564    let mut updated = Vec::with_capacity(phases.len());
2565    let mut warnings = Vec::new();
2566    let mut topology_changed = false;
2567    for phase in phases {
2568        let Some(domain) = phase.reflection_domain.as_ref() else {
2569            updated.push(phase);
2570            continue;
2571        };
2572        let cell = phase.cell.ok_or(LeBailError::InternalInvariant)?;
2573        let previous = phase
2574            .reflection_ids
2575            .iter()
2576            .cloned()
2577            .zip(phase.integrated_intensity.iter().copied())
2578            .collect::<BTreeMap<_, _>>();
2579        let generated = domain
2580            .generate(cell, Some(&previous))
2581            .map_err(LeBailError::Lattice)?;
2582        let changed = generated.reflection_ids != phase.reflection_ids;
2583        topology_changed |= changed;
2584        if !generated.added_reflection_ids.is_empty()
2585            || !generated.removed_reflection_ids.is_empty()
2586        {
2587            warnings.push(format!(
2588                "phase {} reflection domain regenerated: {} added, {} removed",
2589                phase.phase_id(),
2590                generated.added_reflection_ids.len(),
2591                generated.removed_reflection_ids.len()
2592            ));
2593        }
2594        updated.push(phase.replace_generated_domain(cell, generated)?);
2595    }
2596    Ok((updated, warnings, topology_changed))
2597}
2598
2599#[allow(clippy::too_many_arguments)]
2600fn covariance(
2601    pattern: &PatternRecord,
2602    calculation: &LeBailCalculation,
2603    instrument: ConstantWavelengthInstrument,
2604    background: Option<&BackgroundModel>,
2605    phases: &[LeBailPhase],
2606    parameters: Option<&ParameterSet>,
2607    constraints: &[Constraint],
2608    use_uncertainty: bool,
2609    reduced_chi_square: f64,
2610) -> Result<Option<CovarianceMatrix>, LeBailError> {
2611    let Some(parameters) = parameters else {
2612        return Ok(None);
2613    };
2614    let transform = ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2615        .map_err(LeBailError::Constraint)?;
2616    let free_count = transform.free_keys().len();
2617    if free_count == 0 {
2618        return Ok(Some(CovarianceMatrix {
2619            size: 0,
2620            values: Vec::new(),
2621        }));
2622    }
2623    let derivative = transform
2624        .derivative_matrix()
2625        .map_err(LeBailError::Constraint)?;
2626    for (row, spec) in parameters.specs().iter().enumerate() {
2627        if spec.key().module() == "phase"
2628            && spec.key().name() == "scale"
2629            && derivative
2630                .row(row)
2631                .is_some_and(|values| values.iter().any(|value| *value != 0.0))
2632        {
2633            return Ok(None);
2634        }
2635    }
2636    let physical = parameter_columns(
2637        calculation,
2638        parameters,
2639        instrument,
2640        phases,
2641        background,
2642        &pattern.x_deg,
2643    )?;
2644    let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
2645    let jacobian = physical * chain;
2646    let included = pattern
2647        .mask
2648        .clone()
2649        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
2650    let row_count = included.iter().filter(|value| **value).count();
2651    let mut selected = DMatrix::zeros(row_count, free_count);
2652    let mut row = 0;
2653    for sample in 0..pattern.sample_count() {
2654        if !included[sample] {
2655            continue;
2656        }
2657        let weight = if use_uncertainty {
2658            pattern
2659                .uncertainty
2660                .as_ref()
2661                .map_or(1.0, |values| values[sample].recip())
2662        } else {
2663            1.0
2664        };
2665        for column in 0..free_count {
2666            selected[(row, column)] = jacobian[(sample, column)] * weight;
2667        }
2668        row += 1;
2669    }
2670    let normal = selected.transpose() * selected;
2671    if matrix_rank(&normal) != free_count {
2672        return Ok(None);
2673    }
2674    let Some(mut inverse) = normal.try_inverse() else {
2675        return Ok(None);
2676    };
2677    let known_uncertainties = use_uncertainty && pattern.uncertainty.is_some();
2678    if !known_uncertainties && reduced_chi_square.is_finite() {
2679        inverse *= reduced_chi_square;
2680    }
2681    let mut values = Vec::with_capacity(free_count * free_count);
2682    for row in 0..free_count {
2683        for column in 0..free_count {
2684            values.push(inverse[(row, column)]);
2685        }
2686    }
2687    Ok(Some(CovarianceMatrix {
2688        size: free_count,
2689        values,
2690    }))
2691}
2692
2693fn free_parameter_count(
2694    parameters: Option<&ParameterSet>,
2695    constraints: &[Constraint],
2696) -> Result<usize, LeBailError> {
2697    parameters.map_or(Ok(0), |parameters| {
2698        ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2699            .map(|transform| transform.free_keys().len())
2700            .map_err(LeBailError::Constraint)
2701    })
2702}
2703
2704fn matrix_rank(matrix: &DMatrix<f64>) -> usize {
2705    let singular = matrix.clone().svd(false, false).singular_values;
2706    let maximum = singular.iter().copied().fold(0.0_f64, f64::max);
2707    let tolerance = count_as_f64(matrix.nrows().max(matrix.ncols())) * f64::EPSILON * maximum;
2708    singular.iter().filter(|value| **value > tolerance).count()
2709}
2710
2711fn instrument_parameter(instrument: ConstantWavelengthInstrument, name: &str) -> Option<f64> {
2712    match name {
2713        "u_deg2" => Some(instrument.u_deg2),
2714        "v_deg2" => Some(instrument.v_deg2),
2715        "w_deg2" => Some(instrument.w_deg2),
2716        "x_deg" => Some(instrument.x_deg),
2717        "y_deg" => Some(instrument.y_deg),
2718        _ => None,
2719    }
2720}
2721
2722fn set_instrument_parameter(
2723    instrument: &mut ConstantWavelengthInstrument,
2724    name: &str,
2725    value: f64,
2726) -> Result<(), LeBailError> {
2727    match name {
2728        "u_deg2" => instrument.u_deg2 = value,
2729        "v_deg2" => instrument.v_deg2 = value,
2730        "w_deg2" => instrument.w_deg2 = value,
2731        "x_deg" => instrument.x_deg = value,
2732        "y_deg" => instrument.y_deg = value,
2733        _ => {
2734            return Err(LeBailError::UnsupportedParameter {
2735                label: format!("instrument[cw].{name}"),
2736            });
2737        }
2738    }
2739    Ok(())
2740}
2741
2742fn rank_deficient_groups(
2743    calculation: &LeBailCalculation,
2744    threshold: f64,
2745) -> Vec<CoincidentReflectionGroup> {
2746    let local = &calculation.accumulation.derivatives.local;
2747    let count = local.peak_count();
2748    let mut parents = (0..count).collect::<Vec<_>>();
2749    let norms = (0..count)
2750        .map(|reflection| {
2751            let begin = local.offsets[reflection];
2752            let end = local.offsets[reflection + 1];
2753            (begin..end)
2754                .map(|active| {
2755                    let value = local.values[active * local.parameter_count];
2756                    value * value
2757                })
2758                .sum::<f64>()
2759                .sqrt()
2760        })
2761        .collect::<Vec<_>>();
2762    for left in 0..count {
2763        let left_begin = local.offsets[left];
2764        let left_end = local.offsets[left + 1];
2765        let left_start = local.starts[left];
2766        let left_stop = left_start + left_end - left_begin;
2767        for right in left + 1..count {
2768            let right_begin = local.offsets[right];
2769            let right_end = local.offsets[right + 1];
2770            let right_start = local.starts[right];
2771            let right_stop = right_start + right_end - right_begin;
2772            let start = left_start.max(right_start);
2773            let stop = left_stop.min(right_stop);
2774            if start >= stop || norms[left] == 0.0 || norms[right] == 0.0 {
2775                continue;
2776            }
2777            let correlation = (start..stop)
2778                .map(|sample| {
2779                    let left_active = left_begin + sample - left_start;
2780                    let right_active = right_begin + sample - right_start;
2781                    local.values[left_active * local.parameter_count]
2782                        * local.values[right_active * local.parameter_count]
2783                })
2784                .sum::<f64>()
2785                / (norms[left] * norms[right]);
2786            if correlation >= threshold {
2787                union(&mut parents, left, right);
2788            }
2789        }
2790    }
2791    let mut grouped = std::collections::BTreeMap::<usize, Vec<usize>>::new();
2792    for reflection in 0..count {
2793        let root = root(&mut parents, reflection);
2794        grouped.entry(root).or_default().push(reflection);
2795    }
2796    grouped
2797        .into_values()
2798        .filter(|indices| indices.len() > 1)
2799        .map(|indices| {
2800            let first = indices
2801                .iter()
2802                .map(|index| local.starts[*index])
2803                .min()
2804                .unwrap_or(0);
2805            let last = indices
2806                .iter()
2807                .map(|index| {
2808                    local.starts[*index] + local.offsets[*index + 1] - local.offsets[*index]
2809                })
2810                .max()
2811                .unwrap_or(first);
2812            let mut matrix = DMatrix::zeros(last - first, indices.len());
2813            for (column, reflection) in indices.iter().enumerate() {
2814                let begin = local.offsets[*reflection];
2815                let end = local.offsets[*reflection + 1];
2816                let start = local.starts[*reflection] - first;
2817                for active in begin..end {
2818                    matrix[(start + active - begin, column)] =
2819                        local.values[active * local.parameter_count];
2820                }
2821            }
2822            let singular_values = matrix.svd(false, false).singular_values;
2823            let maximum = singular_values.iter().copied().fold(0.0_f64, f64::max);
2824            let tolerance =
2825                count_as_f64((last - first).max(indices.len())) * f64::EPSILON * maximum;
2826            let rank = singular_values
2827                .iter()
2828                .filter(|value| **value > tolerance)
2829                .count();
2830            CoincidentReflectionGroup {
2831                reflection_keys: indices
2832                    .iter()
2833                    .map(|index| calculation.reflection_keys[*index].clone())
2834                    .collect(),
2835                rank,
2836            }
2837        })
2838        .collect()
2839}
2840
2841fn root(parents: &mut [usize], mut index: usize) -> usize {
2842    while parents[index] != index {
2843        parents[index] = parents[parents[index]];
2844        index = parents[index];
2845    }
2846    index
2847}
2848
2849fn union(parents: &mut [usize], left: usize, right: usize) {
2850    let left_root = root(parents, left);
2851    let right_root = root(parents, right);
2852    if left_root != right_root {
2853        parents[right_root] = left_root;
2854    }
2855}
2856
2857fn bin_integration_weights(x: &[f64]) -> Vec<f64> {
2858    match x.len() {
2859        0 => Vec::new(),
2860        1 => vec![1.0],
2861        count => {
2862            let mut widths = vec![0.0; count];
2863            widths[0] = 0.5 * (x[1] - x[0]);
2864            widths[count - 1] = 0.5 * (x[count - 1] - x[count - 2]);
2865            for index in 1..count - 1 {
2866                widths[index] = 0.5 * (x[index + 1] - x[index - 1]);
2867            }
2868            widths
2869        }
2870    }
2871}
2872
2873fn replace_flat_intensities(
2874    phases: &[LeBailPhase],
2875    intensities: &[f64],
2876) -> Result<Vec<LeBailPhase>, LeBailError> {
2877    let expected = phases.iter().map(reflection_count).sum::<usize>();
2878    if intensities.len() != expected {
2879        return Err(LeBailError::IntensityShapeMismatch);
2880    }
2881    let mut offset = 0;
2882    phases
2883        .iter()
2884        .map(|phase| {
2885            let end = offset + reflection_count(phase);
2886            let updated = phase.replace_intensities(&intensities[offset..end]);
2887            offset = end;
2888            updated
2889        })
2890        .collect()
2891}
2892
2893fn flatten_intensities(phases: &[LeBailPhase]) -> Vec<f64> {
2894    phases
2895        .iter()
2896        .flat_map(|phase| phase.integrated_intensity.iter().copied())
2897        .collect()
2898}
2899
2900fn flatten_preserve_mask(phases: &[LeBailPhase]) -> Vec<bool> {
2901    phases
2902        .iter()
2903        .flat_map(|phase| {
2904            if phase.preserve_unobserved.is_empty() {
2905                vec![false; reflection_count(phase)]
2906            } else {
2907                phase.preserve_unobserved.clone()
2908            }
2909        })
2910        .collect()
2911}
2912
2913fn parameter_keys(parameters: &ParameterSet) -> Vec<&ParameterKey> {
2914    parameters.specs().iter().map(ParameterSpec::key).collect()
2915}
2916
2917fn validate_parameter_selection(
2918    phases: &[LeBailPhase],
2919    background: Option<&BackgroundModel>,
2920    parameters: &ParameterSet,
2921) -> Result<(), LeBailError> {
2922    for spec in parameters.specs() {
2923        let key = spec.key();
2924        if key.module() == "background" {
2925            let supported = background.is_some_and(|background| {
2926                background.background_id() == key.owner_id()
2927                    && background
2928                        .parameter_names()
2929                        .iter()
2930                        .any(|name| name == key.name())
2931            });
2932            if !supported {
2933                return Err(LeBailError::UnsupportedParameter { label: key.label() });
2934            }
2935        } else if key.module() == "lattice" {
2936            let phase = phases
2937                .iter()
2938                .find(|phase| phase.phase_id() == key.owner_id())
2939                .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2940            let Some(domain) = phase.reflection_domain.as_ref() else {
2941                return Err(invalid_phase(
2942                    "lattice parameters require bounded dynamic phases",
2943                ));
2944            };
2945            if !domain
2946                .parameterization()
2947                .parameter_names()
2948                .iter()
2949                .any(|name| name == key.name())
2950            {
2951                return Err(LeBailError::UnsupportedParameter { label: key.label() });
2952            }
2953        } else if key.module() == "reflection" {
2954            let dynamic = phases.iter().any(|phase| {
2955                phase.reflection_domain.is_some()
2956                    && key
2957                        .owner_id()
2958                        .strip_prefix(phase.phase_id())
2959                        .is_some_and(|suffix| suffix.starts_with('/'))
2960            });
2961            if dynamic {
2962                return Err(invalid_phase(
2963                    "independent reflection positions require fixed-topology phases",
2964                ));
2965            }
2966        }
2967    }
2968    Ok(())
2969}
2970
2971fn phases_restart_compatible(input: &[LeBailPhase], checkpoint: &[LeBailPhase]) -> bool {
2972    input.len() == checkpoint.len()
2973        && input.iter().zip(checkpoint).all(|(left, right)| {
2974            if left.phase_id() != right.phase_id() {
2975                return false;
2976            }
2977            match (&left.reflection_domain, &right.reflection_domain) {
2978                (None, None) => left.reflection_ids == right.reflection_ids,
2979                (Some(left_domain), Some(right_domain)) => left_domain == right_domain,
2980                _ => false,
2981            }
2982        })
2983}
2984
2985fn backgrounds_restart_compatible(
2986    input: Option<&BackgroundModel>,
2987    checkpoint: Option<&BackgroundModel>,
2988) -> bool {
2989    match (input, checkpoint) {
2990        (None, None) => true,
2991        (Some(input), Some(checkpoint)) => checkpoint.restart_compatible(input),
2992        _ => false,
2993    }
2994}
2995
2996fn reflection_count(phase: &LeBailPhase) -> usize {
2997    phase.reflection_ids.len()
2998}
2999
3000fn reflection_count_of(phase: &LeBailPhase) -> usize {
3001    reflection_count(phase)
3002}
3003
3004fn validate_stable_label(name: &'static str, value: &str) -> Result<(), LeBailError> {
3005    if value.is_empty()
3006        || value.trim() != value
3007        || value.chars().any(char::is_control)
3008        || value.contains('/')
3009    {
3010        return Err(LeBailError::InvalidPhase {
3011            message: format!(
3012                "{name} must be non-empty, trimmed, and contain neither '/' nor control characters"
3013            ),
3014        });
3015    }
3016    Ok(())
3017}
3018
3019fn normal_stop_reason(error: &RuntimeError) -> Option<TerminationReason> {
3020    match error {
3021        RuntimeError::Stopped(stop) => Some(stop.reason),
3022        _ => None,
3023    }
3024}
3025
3026fn stop_reason_or_error(error: RuntimeError) -> Result<TerminationReason, LeBailError> {
3027    normal_stop_reason(&error).ok_or(LeBailError::Runtime(error))
3028}
3029
3030#[allow(clippy::cast_precision_loss)]
3031fn count_as_f64(value: usize) -> f64 {
3032    value as f64
3033}
3034
3035fn invalid_phase(message: &str) -> LeBailError {
3036    LeBailError::InvalidPhase {
3037        message: message.to_owned(),
3038    }
3039}
3040
3041fn invalid_options(message: &str) -> LeBailError {
3042    LeBailError::InvalidOptions {
3043        message: message.to_owned(),
3044    }
3045}
3046
3047/// Invalid native fixed-reflection Le Bail state or operation.
3048#[derive(Debug)]
3049pub enum LeBailError {
3050    /// Pattern domain state is invalid.
3051    Pattern(DomainError),
3052    /// Observations are required.
3053    MissingObservations,
3054    /// Typed parameter construction or replacement failed.
3055    Parameter(ParameterError),
3056    /// Constraint graph or transform failed.
3057    Constraint(ConstraintError),
3058    /// Lattice parameterization, geometry, bounds, or generation failed.
3059    Lattice(LatticeError),
3060    /// Analytical residual-background evaluation failed.
3061    Background(BackgroundError),
3062    /// A parameter key is not supported by fixed-geometry Le Bail.
3063    UnsupportedParameter {
3064        /// Stable parameter label.
3065        label: String,
3066    },
3067    /// A live domain value violates its declared parameter bounds.
3068    ParameterDomainOutsideBounds {
3069        /// Stable parameter label.
3070        label: String,
3071    },
3072    /// Native least-squares solution failed.
3073    LinearSolve,
3074    /// One phase or reflection record is invalid.
3075    InvalidPhase {
3076        /// Stable diagnostic message.
3077        message: String,
3078    },
3079    /// One option is invalid.
3080    InvalidOptions {
3081        /// Stable diagnostic message.
3082        message: String,
3083    },
3084    /// A checkpoint cannot continue this request.
3085    InvalidCheckpoint {
3086        /// Stable diagnostic message.
3087        message: String,
3088    },
3089    /// Current intensities do not match the calculated reflection order.
3090    IntensityShapeMismatch,
3091    /// Preserve-if-unobserved mask does not match the reflection count.
3092    PreserveMaskLengthMismatch,
3093    /// Pattern grid validation failed.
3094    Grid(ProfileError),
3095    /// Native CW accumulation failed.
3096    Calculation(CwContributionsError),
3097    /// Residual evaluation failed.
3098    Residual(ResidualError),
3099    /// Bounded runtime or host callback failed.
3100    Runtime(RuntimeError),
3101    /// Execution policy construction failed.
3102    Execution(ExecutionPolicyError),
3103    /// A workflow-owned allocation/budget count overflowed.
3104    SizeOverflow,
3105    /// A lower-level profile/instrument validation failed.
3106    Profile {
3107        /// Stable diagnostic message.
3108        message: String,
3109    },
3110    /// Private workflow state became inconsistent.
3111    InternalInvariant,
3112}
3113
3114impl Display for LeBailError {
3115    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
3116        match self {
3117            Self::Pattern(error) => Display::fmt(error, formatter),
3118            Self::MissingObservations => {
3119                formatter.write_str("observed_y is required for Le Bail extraction")
3120            }
3121            Self::Parameter(error) => Display::fmt(error, formatter),
3122            Self::Constraint(error) => Display::fmt(error, formatter),
3123            Self::Lattice(error) => Display::fmt(error, formatter),
3124            Self::Background(error) => Display::fmt(error, formatter),
3125            Self::UnsupportedParameter { label } => {
3126                write!(formatter, "unsupported Le Bail parameter {label}")
3127            }
3128            Self::ParameterDomainOutsideBounds { label } => {
3129                write!(
3130                    formatter,
3131                    "domain value for {label} lies outside its bounds"
3132                )
3133            }
3134            Self::LinearSolve => formatter.write_str("profile least-squares solve failed"),
3135            Self::InvalidPhase { message }
3136            | Self::InvalidOptions { message }
3137            | Self::InvalidCheckpoint { message }
3138            | Self::Profile { message } => formatter.write_str(message),
3139            Self::IntensityShapeMismatch => {
3140                formatter.write_str("current intensities must match the reflection count")
3141            }
3142            Self::PreserveMaskLengthMismatch => {
3143                formatter.write_str("preserve_unobserved must match the reflection count")
3144            }
3145            Self::Grid(error) => Display::fmt(error, formatter),
3146            Self::Calculation(error) => Display::fmt(error, formatter),
3147            Self::Residual(error) => Display::fmt(error, formatter),
3148            Self::Runtime(error) => Display::fmt(error, formatter),
3149            Self::Execution(error) => Display::fmt(error, formatter),
3150            Self::SizeOverflow => formatter.write_str("Le Bail workflow size or budget overflowed"),
3151            Self::InternalInvariant => {
3152                formatter.write_str("internal Le Bail workflow state is inconsistent")
3153            }
3154        }
3155    }
3156}
3157
3158impl Error for LeBailError {
3159    fn source(&self) -> Option<&(dyn Error + 'static)> {
3160        match self {
3161            Self::Pattern(error) => Some(error),
3162            Self::Parameter(error) => Some(error),
3163            Self::Constraint(error) => Some(error),
3164            Self::Lattice(error) => Some(error),
3165            Self::Background(error) => Some(error),
3166            Self::Grid(error) => Some(error),
3167            Self::Calculation(error) => Some(error),
3168            Self::Residual(error) => Some(error),
3169            Self::Runtime(error) => Some(error),
3170            Self::Execution(error) => Some(error),
3171            Self::MissingObservations
3172            | Self::UnsupportedParameter { .. }
3173            | Self::ParameterDomainOutsideBounds { .. }
3174            | Self::LinearSolve
3175            | Self::InvalidPhase { .. }
3176            | Self::InvalidOptions { .. }
3177            | Self::InvalidCheckpoint { .. }
3178            | Self::IntensityShapeMismatch
3179            | Self::PreserveMaskLengthMismatch
3180            | Self::SizeOverflow
3181            | Self::Profile { .. }
3182            | Self::InternalInvariant => None,
3183        }
3184    }
3185}