1use 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 Constraint, ConstraintError, ConstraintTransform, DiagnosticValue, GeneratedLatticeDomain,
19 LatticeError, LatticeReflectionDomain, ParameterBounds, ParameterError, ParameterKey,
20 ParameterSet, ParameterSpec, RefinementEventKind, RefinementLimits, RefinementRuntime,
21 ResidualError, ResidualEvaluation, ResidualOptions, RuntimeError, TerminationReason,
22 cw_lattice_geometry, evaluate_residuals,
23};
24
25const INSTRUMENT_PARAMETER_NAMES: [&str; 5] = ["u_deg2", "v_deg2", "w_deg2", "x_deg", "y_deg"];
26const LATTICE_PARAMETER_NAMES: [&str; 6] = [
27 "a_angstrom",
28 "b_angstrom",
29 "c_angstrom",
30 "alpha_deg",
31 "beta_deg",
32 "gamma_deg",
33];
34
35#[derive(Clone, Debug, PartialEq)]
37pub struct LeBailPhase {
38 phase_id: String,
39 name: String,
40 reflection_ids: Vec<String>,
41 hkl: Vec<[i32; 3]>,
42 d_spacing_angstrom: Vec<f64>,
43 two_theta_deg: Vec<f64>,
44 integrated_intensity: Vec<f64>,
45 scale: f64,
46 preserve_unobserved: Vec<bool>,
47 cell: Option<UnitCell>,
48 reflection_domain: Option<LatticeReflectionDomain>,
49}
50
51impl LeBailPhase {
52 #[allow(clippy::too_many_arguments)]
64 pub fn new(
65 phase_id: impl Into<String>,
66 name: impl Into<String>,
67 reflection_ids: Vec<String>,
68 hkl: Vec<[i32; 3]>,
69 d_spacing_angstrom: Vec<f64>,
70 two_theta_deg: Vec<f64>,
71 integrated_intensity: Vec<f64>,
72 scale: f64,
73 preserve_unobserved: Vec<bool>,
74 ) -> Result<Self, LeBailError> {
75 let phase = Self {
76 phase_id: phase_id.into(),
77 name: name.into(),
78 reflection_ids,
79 hkl,
80 d_spacing_angstrom,
81 two_theta_deg,
82 integrated_intensity,
83 scale,
84 preserve_unobserved,
85 cell: None,
86 reflection_domain: None,
87 };
88 phase.validate()?;
89 Ok(phase)
90 }
91
92 pub fn from_lattice_domain(
99 phase_id: impl Into<String>,
100 name: impl Into<String>,
101 cell: UnitCell,
102 scale: f64,
103 reflection_domain: LatticeReflectionDomain,
104 ) -> Result<Self, LeBailError> {
105 let generated = reflection_domain
106 .generate(cell, None)
107 .map_err(LeBailError::Lattice)?;
108 let mut phase = Self::new(
109 phase_id,
110 name,
111 generated.reflection_ids.clone(),
112 generated.hkl.clone(),
113 generated.d_spacing_angstrom.clone(),
114 generated.two_theta_deg.clone(),
115 generated.integrated_intensity.clone(),
116 scale,
117 generated.visible.iter().map(|visible| !visible).collect(),
118 )?;
119 phase.cell = Some(cell);
120 phase.reflection_domain = Some(reflection_domain);
121 phase.validate()?;
122 Ok(phase)
123 }
124
125 fn validate(&self) -> Result<(), LeBailError> {
126 validate_stable_label("phase_id", &self.phase_id)?;
127 if self.name.trim().is_empty() {
128 return Err(invalid_phase("phase name must be non-empty"));
129 }
130 let count = self.reflection_ids.len();
131 if count == 0 {
132 return Err(invalid_phase("at least one reflection is required"));
133 }
134 if self.hkl.len() != count
135 || self.d_spacing_angstrom.len() != count
136 || self.two_theta_deg.len() != count
137 || self.integrated_intensity.len() != count
138 || (!self.preserve_unobserved.is_empty() && self.preserve_unobserved.len() != count)
139 {
140 return Err(invalid_phase("reflection arrays must have equal lengths"));
141 }
142 let mut identities = std::collections::BTreeSet::new();
143 for reflection_id in &self.reflection_ids {
144 validate_stable_label("reflection_id", reflection_id)?;
145 if !identities.insert(reflection_id) {
146 return Err(invalid_phase(
147 "reflection IDs must be unique within a phase",
148 ));
149 }
150 }
151 if self
152 .d_spacing_angstrom
153 .iter()
154 .any(|value| !value.is_finite() || *value <= 0.0)
155 {
156 return Err(invalid_phase("d-spacings must be positive and finite"));
157 }
158 if self
159 .two_theta_deg
160 .iter()
161 .any(|value| !value.is_finite() || *value <= 0.0 || *value >= 180.0)
162 {
163 return Err(invalid_phase(
164 "reflection positions must lie strictly inside (0, 180) degrees",
165 ));
166 }
167 if self
168 .integrated_intensity
169 .iter()
170 .any(|value| !value.is_finite() || *value < 0.0)
171 {
172 return Err(invalid_phase(
173 "integrated intensities must be non-negative and finite",
174 ));
175 }
176 if !self.scale.is_finite() || self.scale < 0.0 {
177 return Err(invalid_phase("phase scale must be non-negative and finite"));
178 }
179 match (&self.cell, &self.reflection_domain) {
180 (None, None) => {}
181 (Some(cell), Some(domain)) => {
182 domain.validate_cell(*cell).map_err(LeBailError::Lattice)?;
183 if self.preserve_unobserved.len() != count {
184 return Err(invalid_phase(
185 "dynamic phases require one visibility marker per reflection",
186 ));
187 }
188 }
189 _ => {
190 return Err(invalid_phase(
191 "dynamic phases require both a cell and reflection domain",
192 ));
193 }
194 }
195 Ok(())
196 }
197
198 #[must_use]
200 pub fn phase_id(&self) -> &str {
201 &self.phase_id
202 }
203
204 #[must_use]
206 pub fn name(&self) -> &str {
207 &self.name
208 }
209
210 #[must_use]
212 pub fn reflection_ids(&self) -> &[String] {
213 &self.reflection_ids
214 }
215
216 #[must_use]
218 pub fn hkl(&self) -> &[[i32; 3]] {
219 &self.hkl
220 }
221
222 #[must_use]
224 pub fn d_spacing_angstrom(&self) -> &[f64] {
225 &self.d_spacing_angstrom
226 }
227
228 #[must_use]
230 pub fn two_theta_deg(&self) -> &[f64] {
231 &self.two_theta_deg
232 }
233
234 #[must_use]
236 pub fn integrated_intensity(&self) -> &[f64] {
237 &self.integrated_intensity
238 }
239
240 pub fn with_integrated_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
247 self.replace_intensities(values)
248 }
249
250 #[must_use]
252 pub const fn scale(&self) -> f64 {
253 self.scale
254 }
255
256 #[must_use]
258 pub fn preserve_unobserved(&self) -> &[bool] {
259 &self.preserve_unobserved
260 }
261
262 #[must_use]
264 pub const fn cell(&self) -> Option<UnitCell> {
265 self.cell
266 }
267
268 #[must_use]
270 pub const fn reflection_domain(&self) -> Option<&LatticeReflectionDomain> {
271 self.reflection_domain.as_ref()
272 }
273
274 pub fn regenerate_lattice_at_cell(&self, cell: UnitCell) -> Result<Self, LeBailError> {
284 let domain = self
285 .reflection_domain
286 .as_ref()
287 .ok_or_else(|| invalid_phase("only dynamic phases have a lattice reflection domain"))?;
288 let previous = self
289 .reflection_ids
290 .iter()
291 .cloned()
292 .zip(self.integrated_intensity.iter().copied())
293 .collect::<BTreeMap<_, _>>();
294 let generated = domain
295 .generate(cell, Some(&previous))
296 .map_err(LeBailError::Lattice)?;
297 self.replace_generated_domain(cell, generated)
298 }
299
300 fn replace_intensities(&self, values: &[f64]) -> Result<Self, LeBailError> {
301 if values.len() != self.integrated_intensity.len()
302 || values
303 .iter()
304 .any(|value| !value.is_finite() || *value < 0.0)
305 {
306 return Err(invalid_phase(
307 "replacement intensities must match and remain non-negative",
308 ));
309 }
310 let mut phase = self.clone();
311 phase.integrated_intensity.copy_from_slice(values);
312 Ok(phase)
313 }
314
315 fn replace_scale_and_positions(
316 &self,
317 scale: f64,
318 positions: Vec<f64>,
319 ) -> Result<Self, LeBailError> {
320 let mut phase = self.clone();
321 phase.scale = scale;
322 phase.two_theta_deg = positions;
323 phase.validate()?;
324 Ok(phase)
325 }
326
327 fn replace_cell_geometry(
328 &self,
329 cell: UnitCell,
330 wavelength_angstrom: f64,
331 ) -> Result<Self, LeBailError> {
332 let domain = self.reflection_domain.as_ref().ok_or_else(|| {
333 invalid_phase("lattice parameters require a bounded reflection domain")
334 })?;
335 domain.validate_cell(cell).map_err(LeBailError::Lattice)?;
336 let geometry = cw_lattice_geometry(
337 domain.parameterization(),
338 cell,
339 &self.hkl,
340 wavelength_angstrom,
341 )
342 .map_err(LeBailError::Lattice)?;
343 let mut phase = self.clone();
344 phase.cell = Some(cell);
345 phase.d_spacing_angstrom = geometry.d_spacing_angstrom;
346 phase.two_theta_deg = geometry.two_theta_deg;
347 phase.validate()?;
348 Ok(phase)
349 }
350
351 fn replace_generated_domain(
352 &self,
353 cell: UnitCell,
354 generated: GeneratedLatticeDomain,
355 ) -> Result<Self, LeBailError> {
356 let mut phase = self.clone();
357 phase.cell = Some(cell);
358 phase.reflection_ids = generated.reflection_ids;
359 phase.hkl = generated.hkl;
360 phase.d_spacing_angstrom = generated.d_spacing_angstrom;
361 phase.two_theta_deg = generated.two_theta_deg;
362 phase.integrated_intensity = generated.integrated_intensity;
363 phase.preserve_unobserved = generated
364 .visible
365 .into_iter()
366 .map(|visible| !visible)
367 .collect();
368 phase.validate()?;
369 Ok(phase)
370 }
371}
372
373pub fn lebail_instrument_parameter_key(name: &str) -> Result<ParameterKey, LeBailError> {
379 if !INSTRUMENT_PARAMETER_NAMES.contains(&name) {
380 return Err(LeBailError::UnsupportedParameter {
381 label: format!("instrument[cw].{name}"),
382 });
383 }
384 ParameterKey::new("instrument", "cw", name).map_err(LeBailError::Parameter)
385}
386
387pub fn lebail_phase_scale_key(phase_id: &str) -> Result<ParameterKey, LeBailError> {
393 ParameterKey::new("phase", phase_id, "scale").map_err(LeBailError::Parameter)
394}
395
396pub fn lebail_lattice_parameter_key(
402 phase_id: &str,
403 name: &str,
404) -> Result<ParameterKey, LeBailError> {
405 if !LATTICE_PARAMETER_NAMES.contains(&name) {
406 return Err(LeBailError::UnsupportedParameter {
407 label: format!("lattice[{phase_id}].{name}"),
408 });
409 }
410 ParameterKey::new("lattice", phase_id, name).map_err(LeBailError::Parameter)
411}
412
413pub fn lebail_reflection_position_key(
419 phase_id: &str,
420 reflection_id: &str,
421) -> Result<ParameterKey, LeBailError> {
422 ParameterKey::new(
423 "reflection",
424 format!("{phase_id}/{reflection_id}"),
425 "two_theta_deg",
426 )
427 .map_err(LeBailError::Parameter)
428}
429
430pub fn build_lebail_parameter_set(
436 instrument: ConstantWavelengthInstrument,
437 phases: &[LeBailPhase],
438 instrument_parameters: &[&str],
439 phase_scales: bool,
440 reflection_positions: bool,
441) -> Result<ParameterSet, LeBailError> {
442 build_lebail_parameter_set_with_lattice(
443 instrument,
444 phases,
445 instrument_parameters,
446 phase_scales,
447 reflection_positions,
448 false,
449 )
450}
451
452pub fn build_lebail_parameter_set_with_lattice(
458 instrument: ConstantWavelengthInstrument,
459 phases: &[LeBailPhase],
460 instrument_parameters: &[&str],
461 phase_scales: bool,
462 reflection_positions: bool,
463 lattice_parameters: bool,
464) -> Result<ParameterSet, LeBailError> {
465 if lattice_parameters && reflection_positions {
466 return Err(invalid_phase(
467 "lattice parameters and independent reflection positions are redundant",
468 ));
469 }
470 let mut specs = Vec::new();
471 for name in instrument_parameters {
472 let key = lebail_instrument_parameter_key(name)?;
473 let value = instrument_parameter(instrument, name)
474 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
475 specs.push(
476 ParameterSpec::new(
477 key,
478 value,
479 if name.ends_with("deg2") {
480 "degree^2"
481 } else {
482 "degree"
483 },
484 ParameterBounds::default(),
485 value.abs().max(if name.ends_with("deg2") {
486 1.0e-5
487 } else {
488 1.0e-4
489 }),
490 true,
491 )
492 .map_err(LeBailError::Parameter)?,
493 );
494 }
495 for phase in phases {
496 if lattice_parameters {
497 append_lattice_parameter_specs(&mut specs, phase)?;
498 }
499 if phase_scales {
500 specs.push(
501 ParameterSpec::new(
502 lebail_phase_scale_key(phase.phase_id())?,
503 phase.scale(),
504 "dimensionless",
505 ParameterBounds::new(0.0, f64::INFINITY).map_err(LeBailError::Parameter)?,
506 phase.scale().max(1.0),
507 true,
508 )
509 .map_err(LeBailError::Parameter)?,
510 );
511 }
512 if reflection_positions {
513 if phase.reflection_domain.is_some() {
514 return Err(invalid_phase(
515 "independent reflection positions require fixed-topology phases",
516 ));
517 }
518 for (reflection_id, position) in phase.reflection_ids.iter().zip(&phase.two_theta_deg) {
519 specs.push(
520 ParameterSpec::new(
521 lebail_reflection_position_key(phase.phase_id(), reflection_id)?,
522 *position,
523 "degree_2theta",
524 ParameterBounds::new(
525 f64::from_bits(1),
526 f64::from_bits(180.0_f64.to_bits() - 1),
527 )
528 .map_err(LeBailError::Parameter)?,
529 0.01,
530 true,
531 )
532 .map_err(LeBailError::Parameter)?,
533 );
534 }
535 }
536 }
537 ParameterSet::new(specs).map_err(LeBailError::Parameter)
538}
539
540fn append_lattice_parameter_specs(
541 specs: &mut Vec<ParameterSpec>,
542 phase: &LeBailPhase,
543) -> Result<(), LeBailError> {
544 let cell = phase
545 .cell
546 .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
547 let domain = phase
548 .reflection_domain
549 .as_ref()
550 .ok_or_else(|| invalid_phase("lattice parameters require bounded dynamic phases"))?;
551 let values = domain
552 .parameterization()
553 .values_from_cell(cell)
554 .map_err(LeBailError::Lattice)?;
555 for (((name, value), lower), upper) in domain
556 .parameterization()
557 .parameter_names()
558 .iter()
559 .zip(values)
560 .zip(domain.bounds().lower())
561 .zip(domain.bounds().upper())
562 {
563 specs.push(
564 ParameterSpec::new(
565 lebail_lattice_parameter_key(phase.phase_id(), name)?,
566 value,
567 if name.ends_with("_angstrom") {
568 "angstrom"
569 } else {
570 "degree"
571 },
572 ParameterBounds::new(*lower, *upper).map_err(LeBailError::Parameter)?,
573 value.abs().max(1.0),
574 true,
575 )
576 .map_err(LeBailError::Parameter)?,
577 );
578 }
579 Ok(())
580}
581
582#[derive(Clone, Debug, PartialEq)]
584pub struct LeBailInput {
585 pub pattern: PatternRecord,
587 pub instrument: ConstantWavelengthInstrument,
589 pub phases: Vec<LeBailPhase>,
591 pub parameters: Option<ParameterSet>,
593 pub constraints: Vec<Constraint>,
595}
596
597impl LeBailInput {
598 pub fn new(
605 pattern: PatternRecord,
606 instrument: ConstantWavelengthInstrument,
607 phases: Vec<LeBailPhase>,
608 ) -> Result<Self, LeBailError> {
609 pattern.validate().map_err(LeBailError::Pattern)?;
610 if pattern.observed_y.is_none() {
611 return Err(LeBailError::MissingObservations);
612 }
613 instrument
614 .validate()
615 .map_err(|error| LeBailError::Profile {
616 message: error.to_string(),
617 })?;
618 if phases.is_empty() {
619 return Err(invalid_phase("at least one phase is required"));
620 }
621 let mut phase_ids = std::collections::BTreeSet::new();
622 for phase in &phases {
623 phase.validate()?;
624 if phase.reflection_domain.as_ref().is_some_and(|domain| {
625 domain.wavelength_angstrom().to_bits() != instrument.wavelength_angstrom.to_bits()
626 }) {
627 return Err(invalid_phase(
628 "dynamic phase wavelength must match the Le Bail instrument",
629 ));
630 }
631 if !phase_ids.insert(phase.phase_id()) {
632 return Err(invalid_phase("phase IDs must be unique"));
633 }
634 }
635 Ok(Self {
636 pattern,
637 instrument,
638 phases,
639 parameters: None,
640 constraints: Vec::new(),
641 })
642 }
643
644 pub fn new_with_parameters(
651 pattern: PatternRecord,
652 instrument: ConstantWavelengthInstrument,
653 phases: Vec<LeBailPhase>,
654 parameters: ParameterSet,
655 constraints: Vec<Constraint>,
656 ) -> Result<Self, LeBailError> {
657 let mut input = Self::new(pattern, instrument, phases)?;
658 validate_parameter_selection(&input.phases, ¶meters)?;
659 domain_parameter_values(input.instrument, &input.phases, ¶meters)?;
660 ConstraintTransform::new(parameters.clone(), constraints.clone())
661 .map_err(LeBailError::Constraint)?;
662 input.parameters = Some(parameters);
663 input.constraints = constraints;
664 Ok(input)
665 }
666}
667
668#[derive(Clone, Debug, PartialEq)]
670pub struct LeBailOptions {
671 pub max_iterations: usize,
673 pub min_iterations: usize,
675 pub intensity_tolerance: f64,
677 pub rwp_tolerance: f64,
679 pub redistribution_damping: f64,
681 pub minimum_calculated: f64,
683 pub initial_intensity_floor: f64,
685 pub use_uncertainty: bool,
687 pub profile_damping: f64,
689 pub max_scaled_parameter_step: f64,
691 pub max_profile_backtracks: usize,
693 pub unresolved_correlation: f64,
695 pub diagnose_rank_deficiency: bool,
697 pub support_fwhm: f64,
699 pub execution: ExecutionPolicy,
701}
702
703impl LeBailOptions {
704 #[allow(clippy::too_many_arguments)]
710 pub fn new(
711 max_iterations: usize,
712 min_iterations: usize,
713 intensity_tolerance: f64,
714 rwp_tolerance: f64,
715 redistribution_damping: f64,
716 minimum_calculated: f64,
717 initial_intensity_floor: f64,
718 use_uncertainty: bool,
719 unresolved_correlation: f64,
720 diagnose_rank_deficiency: bool,
721 support_fwhm: f64,
722 execution: ExecutionPolicy,
723 ) -> Result<Self, LeBailError> {
724 let options = Self {
725 max_iterations,
726 min_iterations,
727 intensity_tolerance,
728 rwp_tolerance,
729 redistribution_damping,
730 minimum_calculated,
731 initial_intensity_floor,
732 use_uncertainty,
733 profile_damping: 1.0e-10,
734 max_scaled_parameter_step: 0.25,
735 max_profile_backtracks: 8,
736 unresolved_correlation,
737 diagnose_rank_deficiency,
738 support_fwhm,
739 execution,
740 };
741 options.validate()?;
742 Ok(options)
743 }
744
745 fn validate(&self) -> Result<(), LeBailError> {
746 if self.max_iterations == 0
747 || self.min_iterations == 0
748 || self.min_iterations > self.max_iterations
749 {
750 return Err(invalid_options(
751 "iteration counts must be positive and minimum must not exceed maximum",
752 ));
753 }
754 for (name, value) in [
755 ("intensity_tolerance", self.intensity_tolerance),
756 ("rwp_tolerance", self.rwp_tolerance),
757 ("minimum_calculated", self.minimum_calculated),
758 ("initial_intensity_floor", self.initial_intensity_floor),
759 ("support_fwhm", self.support_fwhm),
760 ] {
761 if !value.is_finite() || value <= 0.0 {
762 return Err(LeBailError::InvalidOptions {
763 message: format!("{name} must be positive and finite"),
764 });
765 }
766 }
767 if !self.redistribution_damping.is_finite()
768 || self.redistribution_damping <= 0.0
769 || self.redistribution_damping > 1.0
770 {
771 return Err(invalid_options("redistribution_damping must lie in (0, 1]"));
772 }
773 if !self.unresolved_correlation.is_finite()
774 || !(0.0..=1.0).contains(&self.unresolved_correlation)
775 {
776 return Err(invalid_options("unresolved_correlation must lie in [0, 1]"));
777 }
778 if !self.profile_damping.is_finite() || self.profile_damping < 0.0 {
779 return Err(invalid_options(
780 "profile_damping must be non-negative and finite",
781 ));
782 }
783 if !self.max_scaled_parameter_step.is_finite() || self.max_scaled_parameter_step <= 0.0 {
784 return Err(invalid_options(
785 "max_scaled_parameter_step must be positive and finite",
786 ));
787 }
788 Ok(())
789 }
790
791 pub fn with_profile_controls(
797 mut self,
798 profile_damping: f64,
799 max_scaled_parameter_step: f64,
800 max_profile_backtracks: usize,
801 ) -> Result<Self, LeBailError> {
802 self.profile_damping = profile_damping;
803 self.max_scaled_parameter_step = max_scaled_parameter_step;
804 self.max_profile_backtracks = max_profile_backtracks;
805 self.validate()?;
806 Ok(self)
807 }
808
809 pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, LeBailError> {
815 Self::new(
816 50,
817 2,
818 1.0e-6,
819 1.0e-8,
820 1.0,
821 1.0e-15,
822 1.0e-12,
823 true,
824 1.0 - 1.0e-10,
825 false,
826 20.0,
827 execution,
828 )
829 }
830}
831
832#[derive(Clone, Debug, PartialEq)]
834pub struct PhasePatternComponent {
835 pub phase_id: String,
837 pub y: Vec<f64>,
839}
840
841#[derive(Clone, Debug, PartialEq)]
843pub struct LeBailCalculation {
844 pub y: Vec<f64>,
846 pub profile_y: Vec<f64>,
848 pub background_y: Vec<f64>,
850 pub accumulation: Accumulation,
852 pub reflection_keys: Vec<(String, String)>,
854 pub phase_offsets: Vec<usize>,
856 pub phase_components: Vec<PhasePatternComponent>,
858}
859
860#[derive(Clone, Debug, PartialEq)]
862pub struct IntensityExtractionResult {
863 pub intensities: Vec<f64>,
865 pub maximum_relative_change: f64,
867 pub unobserved_reflections: Vec<(String, String)>,
869}
870
871#[derive(Clone, Debug, PartialEq)]
873pub struct ParameterChange {
874 pub key: ParameterKey,
876 pub before: f64,
878 pub after: f64,
880 pub scaled_change: f64,
882}
883
884#[derive(Clone, Debug, PartialEq)]
886pub struct LeBailIterationRecord {
887 pub iteration: usize,
889 pub rp: f64,
891 pub rwp: f64,
893 pub chi_square: f64,
895 pub reduced_chi_square: f64,
897 pub maximum_relative_intensity_change: f64,
899 pub scaled_profile_step_norm: f64,
901 pub parameter_changes: Vec<ParameterChange>,
903 pub warnings: Vec<String>,
905}
906
907#[derive(Clone, Debug, PartialEq)]
909pub struct ReflectionIntensity {
910 pub phase_id: String,
912 pub reflection_id: String,
914 pub integrated_intensity: f64,
916}
917
918#[derive(Clone, Debug, PartialEq, Eq)]
920pub struct CoincidentReflectionGroup {
921 pub reflection_keys: Vec<(String, String)>,
923 pub rank: usize,
925}
926
927#[derive(Clone, Debug, PartialEq)]
929pub struct LeBailCheckpoint {
930 pub completed_iterations: usize,
932 pub phases: Vec<LeBailPhase>,
934 pub instrument: ConstantWavelengthInstrument,
936 pub intensities: Vec<f64>,
938 pub parameters: Option<ParameterSet>,
940 pub previous_rwp: f64,
942 pub history: Vec<LeBailIterationRecord>,
944}
945
946impl LeBailCheckpoint {
947 fn validate(&self) -> Result<(), LeBailError> {
948 if self.completed_iterations != self.history.len() {
949 return Err(LeBailError::InvalidCheckpoint {
950 message: "checkpoint iteration count must equal its history length".to_owned(),
951 });
952 }
953 if self.previous_rwp.is_nan() || self.previous_rwp == f64::NEG_INFINITY {
954 return Err(LeBailError::InvalidCheckpoint {
955 message: "checkpoint previous_rwp must be finite or positive infinity".to_owned(),
956 });
957 }
958 for phase in &self.phases {
959 phase.validate()?;
960 }
961 self.instrument
962 .validate()
963 .map_err(|error| LeBailError::Profile {
964 message: error.to_string(),
965 })?;
966 if self.phases.iter().any(|phase| {
967 phase.reflection_domain.as_ref().is_some_and(|domain| {
968 domain.wavelength_angstrom().to_bits()
969 != self.instrument.wavelength_angstrom.to_bits()
970 })
971 }) {
972 return Err(LeBailError::InvalidCheckpoint {
973 message: "checkpoint dynamic phase wavelength must match its instrument".to_owned(),
974 });
975 }
976 if let Some(parameters) = &self.parameters {
977 validate_parameter_selection(&self.phases, parameters)?;
978 let domain_values = domain_parameter_values(self.instrument, &self.phases, parameters)?;
979 if domain_values != parameters.values() {
980 return Err(LeBailError::InvalidCheckpoint {
981 message: "checkpoint parameters disagree with its live domain".to_owned(),
982 });
983 }
984 }
985 let expected = self.phases.iter().map(reflection_count).sum::<usize>();
986 if self.intensities.len() != expected
987 || self
988 .intensities
989 .iter()
990 .any(|value| !value.is_finite() || *value < 0.0)
991 {
992 return Err(LeBailError::InvalidCheckpoint {
993 message: "checkpoint intensities must match its phases".to_owned(),
994 });
995 }
996 if flatten_intensities(&self.phases) != self.intensities {
997 return Err(LeBailError::InvalidCheckpoint {
998 message: "checkpoint phase and flattened intensities disagree".to_owned(),
999 });
1000 }
1001 if self
1002 .history
1003 .iter()
1004 .enumerate()
1005 .any(|(index, record)| record.iteration != index + 1)
1006 {
1007 return Err(LeBailError::InvalidCheckpoint {
1008 message: "checkpoint history iterations must be contiguous and one-based"
1009 .to_owned(),
1010 });
1011 }
1012 Ok(())
1013 }
1014}
1015
1016#[derive(Clone, Debug, PartialEq)]
1018pub struct LeBailResult {
1019 pub calculation: LeBailCalculation,
1021 pub phases: Vec<LeBailPhase>,
1023 pub instrument: ConstantWavelengthInstrument,
1025 pub intensities: Vec<ReflectionIntensity>,
1027 pub metrics: ResidualEvaluation,
1029 pub history: Vec<LeBailIterationRecord>,
1031 pub termination_reason: TerminationReason,
1033 pub rank_deficient_groups: Vec<CoincidentReflectionGroup>,
1035 pub parameters: Option<ParameterSet>,
1037 pub covariance: Option<CovarianceMatrix>,
1039 pub checkpoint: LeBailCheckpoint,
1041}
1042
1043#[derive(Clone, Debug, PartialEq)]
1048pub struct CovarianceMatrix {
1049 pub size: usize,
1051 pub values: Vec<f64>,
1053}
1054
1055pub fn calculate_lebail_pattern(
1061 pattern: &PatternRecord,
1062 instrument: ConstantWavelengthInstrument,
1063 phases: &[LeBailPhase],
1064 support_fwhm: f64,
1065 execution: &ExecutionPolicy,
1066) -> Result<LeBailCalculation, LeBailError> {
1067 pattern.validate().map_err(LeBailError::Pattern)?;
1068 if phases.is_empty() {
1069 return Err(invalid_phase("at least one phase is required"));
1070 }
1071 if !support_fwhm.is_finite() || support_fwhm <= 0.0 {
1072 return Err(invalid_options("support_fwhm must be positive and finite"));
1073 }
1074 let reflection_count = phases.iter().map(reflection_count).sum::<usize>();
1075 let mut positions = Vec::with_capacity(reflection_count);
1076 let mut intensities = Vec::with_capacity(reflection_count);
1077 let mut multipliers = Vec::with_capacity(reflection_count);
1078 let mut reflection_keys = Vec::with_capacity(reflection_count);
1079 let mut phase_offsets = Vec::with_capacity(phases.len() + 1);
1080 let phase_derivative_count = phases
1081 .len()
1082 .checked_mul(reflection_count)
1083 .ok_or(LeBailError::SizeOverflow)?;
1084 let mut derivative_multipliers = vec![0.0; phase_derivative_count];
1085 phase_offsets.push(0);
1086 for (phase_index, phase) in phases.iter().enumerate() {
1087 phase.validate()?;
1088 let begin = positions.len();
1089 positions.extend_from_slice(&phase.two_theta_deg);
1090 intensities.extend_from_slice(&phase.integrated_intensity);
1091 multipliers.extend(std::iter::repeat_n(phase.scale, reflection_count_of(phase)));
1092 reflection_keys.extend(
1093 phase
1094 .reflection_ids
1095 .iter()
1096 .map(|reflection_id| (phase.phase_id.clone(), reflection_id.clone())),
1097 );
1098 let end = positions.len();
1099 derivative_multipliers
1100 [phase_index * reflection_count + begin..phase_index * reflection_count + end]
1101 .fill(1.0);
1102 phase_offsets.push(end);
1103 }
1104 let contributions = OwnedCwContributions::new(
1105 reflection_count,
1106 phases.len(),
1107 OwnedCwContributionArrays {
1108 gaussian_variance_deg2: vec![0.0; reflection_count],
1109 lorentzian_fwhm_deg: vec![0.0; reflection_count],
1110 intensity_multiplier: multipliers,
1111 d_gaussian_variance_d_position: vec![0.0; reflection_count],
1112 d_lorentzian_fwhm_d_position: vec![0.0; reflection_count],
1113 d_intensity_multiplier_d_position: vec![0.0; reflection_count],
1114 d_gaussian_variance_d_parameters: vec![0.0; phase_derivative_count],
1115 d_lorentzian_fwhm_d_parameters: vec![0.0; phase_derivative_count],
1116 d_intensity_multiplier_d_parameters: derivative_multipliers,
1117 },
1118 )
1119 .map_err(LeBailError::Calculation)?;
1120 let grid = GridView::new(&pattern.x_deg).map_err(LeBailError::Grid)?;
1121 let accumulation = accumulate_cw_contributions_batch_with_context(
1122 grid,
1123 &positions,
1124 &intensities,
1125 instrument,
1126 contributions.as_view(),
1127 SupportPolicy::FwhmMultiple(support_fwhm),
1128 execution.context(),
1129 )
1130 .map_err(LeBailError::Calculation)?;
1131 let profile_y = accumulation.y.clone();
1132 let y = profile_y
1133 .iter()
1134 .zip(&pattern.background_y)
1135 .map(|(profile, background)| profile + background)
1136 .collect::<Vec<_>>();
1137 let mut phase_components = Vec::with_capacity(phases.len());
1138 for (phase_index, phase) in phases.iter().enumerate() {
1139 let mut phase_y = vec![0.0; pattern.sample_count()];
1140 let first = phase_offsets[phase_index];
1141 let last = phase_offsets[phase_index + 1];
1142 for (reflection, intensity) in intensities.iter().enumerate().take(last).skip(first) {
1143 let begin = accumulation.derivatives.local.offsets[reflection];
1144 let end = accumulation.derivatives.local.offsets[reflection + 1];
1145 let start = accumulation.derivatives.local.starts[reflection];
1146 for active in begin..end {
1147 let sample = start + active - begin;
1148 phase_y[sample] += intensity
1149 * accumulation.derivatives.local.values
1150 [active * accumulation.derivatives.local.parameter_count];
1151 }
1152 }
1153 phase_components.push(PhasePatternComponent {
1154 phase_id: phase.phase_id.clone(),
1155 y: phase_y,
1156 });
1157 }
1158 Ok(LeBailCalculation {
1159 y,
1160 profile_y,
1161 background_y: pattern.background_y.clone(),
1162 accumulation,
1163 reflection_keys,
1164 phase_offsets,
1165 phase_components,
1166 })
1167}
1168
1169pub fn initialize_lebail_intensities(
1175 input: &LeBailInput,
1176 options: &LeBailOptions,
1177) -> Result<Vec<f64>, LeBailError> {
1178 options.validate()?;
1179 let values = flatten_intensities(&input.phases);
1180 if values
1181 .iter()
1182 .any(|value| !value.is_finite() || *value < 0.0)
1183 {
1184 return Err(invalid_phase(
1185 "starting intensities must be non-negative and finite",
1186 ));
1187 }
1188 if values.iter().any(|value| *value > 0.0) {
1189 return Ok(values
1190 .into_iter()
1191 .map(|value| value.max(options.initial_intensity_floor))
1192 .collect());
1193 }
1194 let observed = input
1195 .pattern
1196 .observed_y
1197 .as_deref()
1198 .ok_or(LeBailError::MissingObservations)?;
1199 let weights = bin_integration_weights(&input.pattern.x_deg);
1200 let area = observed
1201 .iter()
1202 .zip(&input.pattern.background_y)
1203 .zip(weights)
1204 .map(|((observed, background), width)| (observed - background).max(0.0) * width)
1205 .sum::<f64>();
1206 let starting = (area / count_as_f64(values.len().max(1))).max(options.initial_intensity_floor);
1207 Ok(vec![starting; values.len()])
1208}
1209
1210pub fn extract_lebail_intensities(
1216 pattern: &PatternRecord,
1217 calculation: &LeBailCalculation,
1218 current: &[f64],
1219 options: &LeBailOptions,
1220 preserve_unobserved: &[bool],
1221) -> Result<IntensityExtractionResult, LeBailError> {
1222 options.validate()?;
1223 let observed = pattern
1224 .observed_y
1225 .as_deref()
1226 .ok_or(LeBailError::MissingObservations)?;
1227 let reflection_count = calculation.accumulation.derivatives.local.peak_count();
1228 if current.len() != reflection_count
1229 || current
1230 .iter()
1231 .any(|value| !value.is_finite() || *value < 0.0)
1232 {
1233 return Err(LeBailError::IntensityShapeMismatch);
1234 }
1235 if preserve_unobserved.len() != reflection_count {
1236 return Err(LeBailError::PreserveMaskLengthMismatch);
1237 }
1238 let included = pattern
1239 .mask
1240 .clone()
1241 .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1242 let ratio = observed
1243 .iter()
1244 .zip(&pattern.background_y)
1245 .zip(&calculation.profile_y)
1246 .zip(&included)
1247 .map(|(((observed, background), calculated), included)| {
1248 if *included && *calculated > options.minimum_calculated {
1249 (observed - background).max(0.0) / calculated
1250 } else {
1251 0.0
1252 }
1253 })
1254 .collect::<Vec<_>>();
1255 let mut weights = bin_integration_weights(&pattern.x_deg);
1256 if options.use_uncertainty
1257 && let Some(uncertainty) = &pattern.uncertainty
1258 {
1259 for (weight, uncertainty) in weights.iter_mut().zip(uncertainty) {
1260 *weight /= uncertainty * uncertainty;
1261 }
1262 }
1263 for (weight, included) in weights.iter_mut().zip(&included) {
1264 if !included {
1265 *weight = 0.0;
1266 }
1267 }
1268 let local = &calculation.accumulation.derivatives.local;
1269 let mut updated = vec![0.0; reflection_count];
1270 let mut unobserved_reflections = Vec::new();
1271 for reflection in 0..reflection_count {
1272 let begin = local.offsets[reflection];
1273 let end = local.offsets[reflection + 1];
1274 let start = local.starts[reflection];
1275 let mut denominator = 0.0;
1276 let mut numerator = 0.0;
1277 for active in begin..end {
1278 let sample = start + active - begin;
1279 let profile = local.values[active * local.parameter_count];
1280 let weighted_profile = weights[sample] * profile;
1281 denominator += weighted_profile;
1282 numerator += weighted_profile * ratio[sample];
1283 }
1284 if denominator <= 0.0 {
1285 unobserved_reflections.push(calculation.reflection_keys[reflection].clone());
1286 if preserve_unobserved[reflection] {
1287 updated[reflection] = current[reflection];
1288 }
1289 continue;
1290 }
1291 let raw = (current[reflection] * numerator / denominator).max(0.0);
1292 updated[reflection] =
1293 current[reflection] + options.redistribution_damping * (raw - current[reflection]);
1294 }
1295 let maximum_relative_change = updated
1296 .iter()
1297 .zip(current)
1298 .map(|(updated, current)| {
1299 (updated - current).abs() / current.abs().max(options.initial_intensity_floor)
1300 })
1301 .fold(0.0_f64, f64::max);
1302 Ok(IntensityExtractionResult {
1303 intensities: updated,
1304 maximum_relative_change,
1305 unobserved_reflections,
1306 })
1307}
1308
1309pub fn refine_lebail(
1316 input: &LeBailInput,
1317 options: &LeBailOptions,
1318 checkpoint: Option<&LeBailCheckpoint>,
1319) -> Result<LeBailResult, LeBailError> {
1320 let evaluations_per_iteration = options
1321 .max_profile_backtracks
1322 .checked_add(2)
1323 .ok_or(LeBailError::SizeOverflow)?;
1324 let max_evaluations = options
1325 .max_iterations
1326 .checked_mul(evaluations_per_iteration)
1327 .and_then(|value| value.checked_add(1))
1328 .ok_or(LeBailError::SizeOverflow)?;
1329 let limits = RefinementLimits::new(options.max_iterations, max_evaluations, None, 1)
1330 .map_err(LeBailError::Runtime)?;
1331 let mut runtime = RefinementRuntime::new(limits, None).map_err(LeBailError::Runtime)?;
1332 refine_lebail_with_runtime(input, options, checkpoint, &mut runtime)
1333}
1334
1335pub fn iterate_lebail_once(
1346 input: &LeBailInput,
1347 options: &LeBailOptions,
1348 checkpoint: Option<&LeBailCheckpoint>,
1349) -> Result<LeBailResult, LeBailError> {
1350 let completed = checkpoint.map_or(0, |value| value.completed_iterations);
1351 let iteration = completed.checked_add(1).ok_or(LeBailError::SizeOverflow)?;
1352 let mut selected = options.clone();
1353 selected.min_iterations = iteration;
1354 selected.max_iterations = iteration;
1355 selected.validate()?;
1356 refine_lebail(input, &selected, checkpoint)
1357}
1358
1359pub fn refine_lebail_with_runtime(
1369 input: &LeBailInput,
1370 options: &LeBailOptions,
1371 checkpoint: Option<&LeBailCheckpoint>,
1372 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1373) -> Result<LeBailResult, LeBailError> {
1374 options.validate()?;
1375 let mut state = restore_state(input, options, checkpoint)?;
1376 if let Some(checkpoint) = checkpoint {
1377 runtime
1378 .resume_accepted(checkpoint.completed_iterations)
1379 .map_err(LeBailError::Runtime)?;
1380 }
1381 runtime
1382 .emit(
1383 RefinementEventKind::Start,
1384 "lebail",
1385 "Le Bail extraction started",
1386 Vec::new(),
1387 )
1388 .map_err(LeBailError::Runtime)?;
1389 state.calculation = Some(calculate_lebail_pattern(
1390 &input.pattern,
1391 state.instrument,
1392 &state.phases,
1393 options.support_fwhm,
1394 &options.execution,
1395 )?);
1396 let termination = run_lebail_iterations(input, options, &mut state, runtime)?;
1397 finish_result(
1398 input,
1399 options,
1400 state.phases,
1401 state.instrument,
1402 &state.intensities,
1403 state.parameters,
1404 state.history,
1405 state.previous_rwp,
1406 state.calculation.ok_or(LeBailError::InternalInvariant)?,
1407 termination,
1408 runtime,
1409 )
1410}
1411
1412fn run_lebail_iterations(
1413 input: &LeBailInput,
1414 options: &LeBailOptions,
1415 state: &mut RestoredLeBailState,
1416 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1417) -> Result<TerminationReason, LeBailError> {
1418 if let Err(error) = runtime.begin_evaluation() {
1419 return stop_reason_or_error(error);
1420 }
1421 for iteration in state.first_iteration..=options.max_iterations {
1422 if let Err(error) = runtime.begin_iteration(iteration) {
1423 return stop_reason_or_error(error);
1424 }
1425 if let Err(error) = runtime.begin_evaluation() {
1426 return stop_reason_or_error(error);
1427 }
1428 let candidate = match evaluate_lebail_iteration(input, options, state, runtime) {
1429 Ok(candidate) => candidate,
1430 Err(LeBailError::Runtime(error)) if normal_stop_reason(&error).is_some() => {
1431 return stop_reason_or_error(error);
1432 }
1433 Err(error) => return Err(error),
1434 };
1435 state.history.push(LeBailIterationRecord {
1436 iteration,
1437 rp: candidate.metrics.rp,
1438 rwp: candidate.metrics.rwp,
1439 chi_square: candidate.metrics.chi_square,
1440 reduced_chi_square: candidate.metrics.reduced_chi_square,
1441 maximum_relative_intensity_change: candidate.extraction.maximum_relative_change,
1442 scaled_profile_step_norm: candidate.profile_step_norm,
1443 parameter_changes: candidate.parameter_changes,
1444 warnings: candidate.warnings,
1445 });
1446 state.instrument = candidate.instrument;
1447 state.phases = candidate.phases;
1448 state.parameters = candidate.parameters;
1449 state.intensities = candidate.extraction.intensities;
1450 state.calculation = Some(candidate.calculation);
1451 accept_lebail_iteration(runtime, state, &candidate.metrics)?;
1452 if iteration >= options.min_iterations
1453 && candidate.extraction.maximum_relative_change < options.intensity_tolerance
1454 && (state.previous_rwp - candidate.metrics.rwp).abs() < options.rwp_tolerance
1455 {
1456 return Ok(TerminationReason::Converged);
1457 }
1458 state.previous_rwp = candidate.metrics.rwp;
1459 }
1460 Ok(TerminationReason::MaxIterations)
1461}
1462
1463struct EvaluatedLeBailIteration {
1464 extraction: IntensityExtractionResult,
1465 phases: Vec<LeBailPhase>,
1466 calculation: LeBailCalculation,
1467 metrics: ResidualEvaluation,
1468 warnings: Vec<String>,
1469 instrument: ConstantWavelengthInstrument,
1470 parameters: Option<ParameterSet>,
1471 profile_step_norm: f64,
1472 parameter_changes: Vec<ParameterChange>,
1473}
1474
1475fn evaluate_lebail_iteration(
1476 input: &LeBailInput,
1477 options: &LeBailOptions,
1478 state: &RestoredLeBailState,
1479 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1480) -> Result<EvaluatedLeBailIteration, LeBailError> {
1481 let mut extraction = extract_lebail_intensities(
1482 &input.pattern,
1483 state.calculation()?,
1484 &state.intensities,
1485 options,
1486 &flatten_preserve_mask(&state.phases),
1487 )?;
1488 let phases = replace_flat_intensities(&state.phases, &extraction.intensities)?;
1489 let calculation = calculate_lebail_pattern(
1490 &input.pattern,
1491 state.instrument,
1492 &phases,
1493 options.support_fwhm,
1494 &options.execution,
1495 )?;
1496 let profile = profile_update(
1497 &input.pattern,
1498 state.instrument,
1499 phases,
1500 calculation,
1501 state.parameters.as_ref(),
1502 &input.constraints,
1503 options,
1504 runtime,
1505 )?;
1506 extraction.intensities = flatten_intensities(&profile.phases);
1507 let parameter_count = free_parameter_count(profile.parameters.as_ref(), &input.constraints)?;
1508 let metrics = evaluate_residuals(
1509 &input.pattern,
1510 &profile.calculation.y,
1511 ResidualOptions {
1512 use_uncertainty: options.use_uncertainty,
1513 parameter_count,
1514 },
1515 )
1516 .map_err(LeBailError::Residual)?;
1517 let warnings = if extraction.unobserved_reflections.is_empty() {
1518 Vec::new()
1519 } else {
1520 vec![format!(
1521 "{} reflections have no included support",
1522 extraction.unobserved_reflections.len()
1523 )]
1524 };
1525 Ok(EvaluatedLeBailIteration {
1526 extraction,
1527 phases: profile.phases,
1528 calculation: profile.calculation,
1529 metrics,
1530 warnings: [warnings, profile.warnings].concat(),
1531 instrument: profile.instrument,
1532 parameters: profile.parameters,
1533 profile_step_norm: profile.step_norm,
1534 parameter_changes: profile.parameter_changes,
1535 })
1536}
1537
1538fn accept_lebail_iteration(
1539 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1540 state: &RestoredLeBailState,
1541 metrics: &ResidualEvaluation,
1542) -> Result<(), LeBailError> {
1543 let checkpoint = LeBailCheckpoint {
1544 completed_iterations: state.history.len(),
1545 phases: state.phases.clone(),
1546 instrument: state.instrument,
1547 intensities: state.intensities.clone(),
1548 parameters: state.parameters.clone(),
1549 previous_rwp: metrics.rwp,
1550 history: state.history.clone(),
1551 };
1552 runtime
1553 .accept_step(Some(&checkpoint))
1554 .map_err(LeBailError::Runtime)?;
1555 runtime
1556 .emit(
1557 RefinementEventKind::Iteration,
1558 "lebail_iteration",
1559 "Le Bail iteration accepted",
1560 vec![
1561 ("rwp".to_owned(), DiagnosticValue::Float(metrics.rwp)),
1562 (
1563 "maximum_relative_intensity_change".to_owned(),
1564 DiagnosticValue::Float(
1565 state
1566 .history
1567 .last()
1568 .ok_or(LeBailError::InternalInvariant)?
1569 .maximum_relative_intensity_change,
1570 ),
1571 ),
1572 ],
1573 )
1574 .map_err(LeBailError::Runtime)?;
1575 Ok(())
1576}
1577
1578#[allow(clippy::too_many_arguments)]
1579fn finish_result(
1580 input: &LeBailInput,
1581 options: &LeBailOptions,
1582 phases: Vec<LeBailPhase>,
1583 instrument: ConstantWavelengthInstrument,
1584 intensities: &[f64],
1585 parameters: Option<ParameterSet>,
1586 history: Vec<LeBailIterationRecord>,
1587 previous_rwp: f64,
1588 calculation: LeBailCalculation,
1589 termination: TerminationReason,
1590 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1591) -> Result<LeBailResult, LeBailError> {
1592 let metrics = evaluate_residuals(
1593 &input.pattern,
1594 &calculation.y,
1595 ResidualOptions {
1596 use_uncertainty: options.use_uncertainty,
1597 parameter_count: free_parameter_count(parameters.as_ref(), &input.constraints)?,
1598 },
1599 )
1600 .map_err(LeBailError::Residual)?;
1601 let checkpoint = LeBailCheckpoint {
1602 completed_iterations: history.len(),
1603 phases: phases.clone(),
1604 instrument,
1605 intensities: intensities.to_owned(),
1606 parameters: parameters.clone(),
1607 previous_rwp: if termination == TerminationReason::Cancelled {
1608 previous_rwp
1609 } else {
1610 metrics.rwp
1611 },
1612 history: history.clone(),
1613 };
1614 checkpoint.validate()?;
1615 let labeled = calculation
1616 .reflection_keys
1617 .iter()
1618 .zip(intensities)
1619 .map(
1620 |((phase_id, reflection_id), intensity)| ReflectionIntensity {
1621 phase_id: phase_id.clone(),
1622 reflection_id: reflection_id.clone(),
1623 integrated_intensity: *intensity,
1624 },
1625 )
1626 .collect();
1627 let rank_deficient_groups = if options.diagnose_rank_deficiency {
1628 rank_deficient_groups(&calculation, options.unresolved_correlation)
1629 } else {
1630 Vec::new()
1631 };
1632 let covariance = covariance(
1633 &input.pattern,
1634 &calculation,
1635 instrument,
1636 &phases,
1637 parameters.as_ref(),
1638 &input.constraints,
1639 options.use_uncertainty,
1640 metrics.reduced_chi_square,
1641 )?;
1642 runtime
1643 .emit(
1644 RefinementEventKind::Termination,
1645 "lebail",
1646 "Le Bail extraction terminated",
1647 vec![(
1648 "termination_reason".to_owned(),
1649 DiagnosticValue::String(termination.as_str().to_owned()),
1650 )],
1651 )
1652 .map_err(LeBailError::Runtime)?;
1653 Ok(LeBailResult {
1654 calculation,
1655 phases,
1656 instrument,
1657 intensities: labeled,
1658 metrics,
1659 history,
1660 termination_reason: termination,
1661 rank_deficient_groups,
1662 parameters,
1663 covariance,
1664 checkpoint,
1665 })
1666}
1667
1668struct RestoredLeBailState {
1669 phases: Vec<LeBailPhase>,
1670 instrument: ConstantWavelengthInstrument,
1671 intensities: Vec<f64>,
1672 history: Vec<LeBailIterationRecord>,
1673 parameters: Option<ParameterSet>,
1674 previous_rwp: f64,
1675 first_iteration: usize,
1676 calculation: Option<LeBailCalculation>,
1677}
1678
1679impl RestoredLeBailState {
1680 fn calculation(&self) -> Result<&LeBailCalculation, LeBailError> {
1681 self.calculation
1682 .as_ref()
1683 .ok_or(LeBailError::InternalInvariant)
1684 }
1685}
1686
1687fn restore_state(
1688 input: &LeBailInput,
1689 options: &LeBailOptions,
1690 checkpoint: Option<&LeBailCheckpoint>,
1691) -> Result<RestoredLeBailState, LeBailError> {
1692 let Some(checkpoint) = checkpoint else {
1693 let intensities = initialize_lebail_intensities(input, options)?;
1694 let phases = replace_flat_intensities(&input.phases, &intensities)?;
1695 return Ok(RestoredLeBailState {
1696 phases,
1697 instrument: input.instrument,
1698 intensities,
1699 history: Vec::new(),
1700 parameters: input.parameters.clone(),
1701 previous_rwp: f64::INFINITY,
1702 first_iteration: 1,
1703 calculation: None,
1704 });
1705 };
1706 checkpoint.validate()?;
1707 if checkpoint.completed_iterations >= options.max_iterations {
1708 return Err(LeBailError::InvalidCheckpoint {
1709 message: "checkpoint already reached the configured maximum iteration".to_owned(),
1710 });
1711 }
1712 if !phases_restart_compatible(&input.phases, &checkpoint.phases) {
1713 return Err(LeBailError::InvalidCheckpoint {
1714 message: "checkpoint phase/reflection domain does not match the input".to_owned(),
1715 });
1716 }
1717 let input_parameter_keys = input.parameters.as_ref().map(parameter_keys);
1718 let checkpoint_parameter_keys = checkpoint.parameters.as_ref().map(parameter_keys);
1719 if input_parameter_keys != checkpoint_parameter_keys {
1720 return Err(LeBailError::InvalidCheckpoint {
1721 message: "checkpoint parameter identities do not match the input".to_owned(),
1722 });
1723 }
1724 Ok(RestoredLeBailState {
1725 phases: checkpoint.phases.clone(),
1726 instrument: checkpoint.instrument,
1727 intensities: checkpoint.intensities.clone(),
1728 history: checkpoint.history.clone(),
1729 parameters: checkpoint.parameters.clone(),
1730 previous_rwp: checkpoint.previous_rwp,
1731 first_iteration: checkpoint.completed_iterations + 1,
1732 calculation: None,
1733 })
1734}
1735
1736struct ProfileUpdate {
1737 instrument: ConstantWavelengthInstrument,
1738 phases: Vec<LeBailPhase>,
1739 calculation: LeBailCalculation,
1740 parameters: Option<ParameterSet>,
1741 step_norm: f64,
1742 parameter_changes: Vec<ParameterChange>,
1743 warnings: Vec<String>,
1744}
1745
1746#[allow(clippy::too_many_arguments)]
1747#[allow(clippy::too_many_lines)]
1751fn profile_update(
1752 pattern: &PatternRecord,
1753 instrument: ConstantWavelengthInstrument,
1754 phases: Vec<LeBailPhase>,
1755 calculation: LeBailCalculation,
1756 parameters: Option<&ParameterSet>,
1757 constraints: &[Constraint],
1758 options: &LeBailOptions,
1759 runtime: &mut RefinementRuntime<LeBailCheckpoint>,
1760) -> Result<ProfileUpdate, LeBailError> {
1761 let Some(parameters) = parameters else {
1762 return Ok(ProfileUpdate {
1763 instrument,
1764 phases,
1765 calculation,
1766 parameters: None,
1767 step_norm: 0.0,
1768 parameter_changes: Vec::new(),
1769 warnings: Vec::new(),
1770 });
1771 };
1772 let domain_values = domain_parameter_values(instrument, &phases, parameters)?;
1773 let current = parameters
1774 .replace_values(&domain_values)
1775 .map_err(LeBailError::Parameter)?;
1776 let transform = ConstraintTransform::new(current.clone(), constraints.to_vec())
1777 .map_err(LeBailError::Constraint)?;
1778 if transform.free_keys().is_empty() {
1779 return Ok(ProfileUpdate {
1780 instrument,
1781 phases,
1782 calculation,
1783 parameters: Some(current),
1784 step_norm: 0.0,
1785 parameter_changes: Vec::new(),
1786 warnings: Vec::new(),
1787 });
1788 }
1789 let physical = parameter_columns(&calculation, ¤t, instrument, &phases)?;
1790 let derivative = transform
1791 .derivative_matrix()
1792 .map_err(LeBailError::Constraint)?;
1793 let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
1794 let jacobian = physical * chain;
1795 let observed = pattern
1796 .observed_y
1797 .as_deref()
1798 .ok_or(LeBailError::MissingObservations)?;
1799 let included = pattern
1800 .mask
1801 .clone()
1802 .unwrap_or_else(|| vec![true; pattern.sample_count()]);
1803 let selected_count = included.iter().filter(|value| **value).count();
1804 let free_count = transform.free_keys().len();
1805 let mut selected_jacobian = DMatrix::zeros(selected_count, free_count);
1806 let mut selected_residual = DVector::zeros(selected_count);
1807 let mut selected_row = 0;
1808 for sample in 0..pattern.sample_count() {
1809 if !included[sample] {
1810 continue;
1811 }
1812 let weight = if options.use_uncertainty {
1813 pattern
1814 .uncertainty
1815 .as_ref()
1816 .map_or(1.0, |values| values[sample].recip())
1817 } else {
1818 1.0
1819 };
1820 selected_residual[selected_row] = (observed[sample] - calculation.y[sample]) * weight;
1821 for column in 0..free_count {
1822 selected_jacobian[(selected_row, column)] = jacobian[(sample, column)] * weight;
1823 }
1824 selected_row += 1;
1825 }
1826 let normal = selected_jacobian.transpose() * &selected_jacobian;
1827 let mut warnings = Vec::new();
1828 if matrix_rank(&normal) != free_count {
1829 warnings.push("profile Jacobian is rank deficient".to_owned());
1830 }
1831 if current
1832 .specs()
1833 .iter()
1834 .any(|spec| spec.key().module() == "phase" && spec.key().name() == "scale")
1835 {
1836 warnings.push(
1837 "phase scale is not identifiable independently of extracted Le Bail intensities"
1838 .to_owned(),
1839 );
1840 }
1841 let base = transform.pack().map_err(LeBailError::Constraint)?;
1842 let mut lower = vec![-options.max_scaled_parameter_step; free_count];
1843 let mut upper = vec![options.max_scaled_parameter_step; free_count];
1844 for (index, key) in transform.free_keys().iter().enumerate() {
1845 let spec = current.spec(key).ok_or(LeBailError::InternalInvariant)?;
1846 lower[index] = lower[index].max(spec.bounds().lower() / spec.scale() - base[index]);
1847 upper[index] = upper[index].min(spec.bounds().upper() / spec.scale() - base[index]);
1848 }
1849 let rhs = selected_jacobian.transpose() * &selected_residual;
1850 let mut regularized = normal;
1851 for index in 0..free_count {
1852 regularized[(index, index)] += options.profile_damping;
1853 }
1854 let mut step = if let Some(solution) = regularized.lu().solve(&rhs) {
1855 solution
1856 } else {
1857 warnings.push("profile normal equations used least-squares fallback".to_owned());
1858 selected_jacobian
1859 .clone()
1860 .svd(true, true)
1861 .solve(&selected_residual, f64::EPSILON)
1862 .map_err(|_| LeBailError::LinearSolve)?
1863 };
1864 for index in 0..free_count {
1865 step[index] = step[index].clamp(lower[index], upper[index]);
1866 }
1867 let baseline = evaluate_residuals(
1868 pattern,
1869 &calculation.y,
1870 ResidualOptions {
1871 use_uncertainty: options.use_uncertainty,
1872 parameter_count: free_count,
1873 },
1874 )
1875 .map_err(LeBailError::Residual)?;
1876 let mut factor = 1.0;
1877 for _ in 0..=options.max_profile_backtracks {
1878 let trial = base
1879 .iter()
1880 .zip(step.iter())
1881 .map(|(base, step)| base + factor * step)
1882 .collect::<Vec<_>>();
1883 let Ok(values) = transform.unpack(&trial, true) else {
1884 factor *= 0.5;
1885 continue;
1886 };
1887 let Ok((candidate_instrument, candidate_phases)) =
1888 apply_parameter_values(instrument, &phases, &values)
1889 else {
1890 factor *= 0.5;
1891 continue;
1892 };
1893 runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
1894 let Ok(candidate_calculation) = calculate_lebail_pattern(
1895 pattern,
1896 candidate_instrument,
1897 &candidate_phases,
1898 options.support_fwhm,
1899 &options.execution,
1900 ) else {
1901 factor *= 0.5;
1902 continue;
1903 };
1904 let candidate_metrics = evaluate_residuals(
1905 pattern,
1906 &candidate_calculation.y,
1907 ResidualOptions {
1908 use_uncertainty: options.use_uncertainty,
1909 parameter_count: free_count,
1910 },
1911 )
1912 .map_err(LeBailError::Residual)?;
1913 if candidate_metrics.chi_square < baseline.chi_square {
1914 let candidate_parameters = current
1915 .replace_values(&values)
1916 .map_err(LeBailError::Parameter)?;
1917 let (candidate_phases, domain_warnings, topology_changed) =
1918 regenerate_accepted_domains(candidate_phases)?;
1919 let candidate_calculation = if topology_changed {
1920 runtime.begin_evaluation().map_err(LeBailError::Runtime)?;
1921 calculate_lebail_pattern(
1922 pattern,
1923 candidate_instrument,
1924 &candidate_phases,
1925 options.support_fwhm,
1926 &options.execution,
1927 )?
1928 } else {
1929 candidate_calculation
1930 };
1931 let parameter_changes = current
1932 .specs()
1933 .iter()
1934 .filter_map(|spec| {
1935 let after = candidate_parameters.spec(spec.key())?.value();
1936 (after.to_bits() != spec.value().to_bits()).then(|| ParameterChange {
1937 key: spec.key().clone(),
1938 before: spec.value(),
1939 after,
1940 scaled_change: (after - spec.value()) / spec.scale(),
1941 })
1942 })
1943 .collect();
1944 return Ok(ProfileUpdate {
1945 instrument: candidate_instrument,
1946 phases: candidate_phases,
1947 calculation: candidate_calculation,
1948 parameters: Some(candidate_parameters),
1949 step_norm: factor * step.norm(),
1950 parameter_changes,
1951 warnings: [warnings, domain_warnings].concat(),
1952 });
1953 }
1954 factor *= 0.5;
1955 }
1956 warnings.push("profile step rejected by backtracking".to_owned());
1957 Ok(ProfileUpdate {
1958 instrument,
1959 phases,
1960 calculation,
1961 parameters: Some(current),
1962 step_norm: 0.0,
1963 parameter_changes: Vec::new(),
1964 warnings,
1965 })
1966}
1967
1968fn domain_parameter_values(
1969 instrument: ConstantWavelengthInstrument,
1970 phases: &[LeBailPhase],
1971 parameters: &ParameterSet,
1972) -> Result<BTreeMap<ParameterKey, f64>, LeBailError> {
1973 let mut values = BTreeMap::new();
1974 for spec in parameters.specs() {
1975 let key = spec.key();
1976 let value = if key.module() == "instrument" && key.owner_id() == "cw" {
1977 instrument_parameter(instrument, key.name())
1978 } else if key.module() == "phase" && key.name() == "scale" {
1979 phases
1980 .iter()
1981 .find(|phase| phase.phase_id() == key.owner_id())
1982 .map(LeBailPhase::scale)
1983 } else if key.module() == "lattice" {
1984 phases
1985 .iter()
1986 .find(|phase| phase.phase_id() == key.owner_id())
1987 .and_then(|phase| {
1988 let cell = phase.cell?;
1989 let parameterization = phase.reflection_domain.as_ref()?.parameterization();
1990 let index = parameterization
1991 .parameter_names()
1992 .iter()
1993 .position(|name| name == key.name())?;
1994 parameterization
1995 .values_from_cell(cell)
1996 .ok()?
1997 .get(index)
1998 .copied()
1999 })
2000 } else if key.module() == "reflection" && key.name() == "two_theta_deg" {
2001 phases.iter().find_map(|phase| {
2002 phase
2003 .reflection_ids
2004 .iter()
2005 .position(|reflection_id| {
2006 format!("{}/{}", phase.phase_id(), reflection_id) == key.owner_id()
2007 })
2008 .map(|index| phase.two_theta_deg[index])
2009 })
2010 } else {
2011 None
2012 }
2013 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2014 if !spec.bounds().contains(value) {
2015 return Err(LeBailError::ParameterDomainOutsideBounds { label: key.label() });
2016 }
2017 values.insert(key.clone(), value);
2018 }
2019 Ok(values)
2020}
2021
2022fn parameter_columns(
2023 calculation: &LeBailCalculation,
2024 parameters: &ParameterSet,
2025 instrument: ConstantWavelengthInstrument,
2026 phases: &[LeBailPhase],
2027) -> Result<DMatrix<f64>, LeBailError> {
2028 let samples = calculation.y.len();
2029 let mut matrix = DMatrix::zeros(samples, parameters.specs().len());
2030 let global = calculation
2031 .accumulation
2032 .derivatives
2033 .global
2034 .as_ref()
2035 .ok_or(LeBailError::InternalInvariant)?;
2036 for (column, spec) in parameters.specs().iter().enumerate() {
2037 let key = spec.key();
2038 if key.module() == "instrument" {
2039 let row = INSTRUMENT_PARAMETER_NAMES
2040 .iter()
2041 .position(|name| *name == key.name())
2042 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2043 for sample in 0..samples {
2044 matrix[(sample, column)] = global.values[row * samples + sample];
2045 }
2046 } else if key.module() == "phase" {
2047 let phase = phases
2048 .iter()
2049 .position(|phase| phase.phase_id() == key.owner_id())
2050 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2051 let row = 5 + phase;
2052 for sample in 0..samples {
2053 matrix[(sample, column)] = global.values[row * samples + sample];
2054 }
2055 } else if key.module() == "reflection" {
2056 let reflection = calculation
2057 .reflection_keys
2058 .iter()
2059 .position(|(phase_id, reflection_id)| {
2060 format!("{phase_id}/{reflection_id}") == key.owner_id()
2061 })
2062 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2063 let local = &calculation.accumulation.derivatives.local;
2064 let begin = local.offsets[reflection];
2065 let end = local.offsets[reflection + 1];
2066 let start = local.starts[reflection];
2067 for active in begin..end {
2068 matrix[(start + active - begin, column)] =
2069 local.values[active * local.parameter_count + 1];
2070 }
2071 } else if key.module() == "lattice" {
2072 let phase = phases
2073 .iter()
2074 .find(|phase| phase.phase_id() == key.owner_id())
2075 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2076 let cell = phase
2077 .cell
2078 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2079 let domain = phase
2080 .reflection_domain
2081 .as_ref()
2082 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2083 let geometry = cw_lattice_geometry(
2084 domain.parameterization(),
2085 cell,
2086 &phase.hkl,
2087 instrument.wavelength_angstrom,
2088 )
2089 .map_err(LeBailError::Lattice)?;
2090 let parameter = geometry
2091 .parameter_names
2092 .iter()
2093 .position(|name| name == key.name())
2094 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2095 let local = &calculation.accumulation.derivatives.local;
2096 for (phase_reflection, reflection_id) in phase.reflection_ids.iter().enumerate() {
2097 let reflection = calculation
2098 .reflection_keys
2099 .iter()
2100 .position(|(phase_id, candidate_id)| {
2101 phase_id == phase.phase_id() && candidate_id == reflection_id
2102 })
2103 .ok_or(LeBailError::InternalInvariant)?;
2104 let derivative = geometry.d_two_theta_d_parameters
2105 [phase_reflection * geometry.parameter_names.len() + parameter];
2106 let begin = local.offsets[reflection];
2107 let end = local.offsets[reflection + 1];
2108 let start = local.starts[reflection];
2109 for active in begin..end {
2110 matrix[(start + active - begin, column)] +=
2111 local.values[active * local.parameter_count + 1] * derivative;
2112 }
2113 }
2114 } else {
2115 return Err(LeBailError::UnsupportedParameter { label: key.label() });
2116 }
2117 }
2118 Ok(matrix)
2119}
2120
2121fn apply_parameter_values(
2122 instrument: ConstantWavelengthInstrument,
2123 phases: &[LeBailPhase],
2124 values: &BTreeMap<ParameterKey, f64>,
2125) -> Result<(ConstantWavelengthInstrument, Vec<LeBailPhase>), LeBailError> {
2126 let mut updated_instrument = instrument;
2127 for (key, value) in values {
2128 if key.module() == "instrument" {
2129 set_instrument_parameter(&mut updated_instrument, key.name(), *value)?;
2130 }
2131 }
2132 updated_instrument
2133 .validate()
2134 .map_err(|error| LeBailError::Profile {
2135 message: error.to_string(),
2136 })?;
2137 let mut updated_phases = Vec::with_capacity(phases.len());
2138 for phase in phases {
2139 let scale = values
2140 .get(&lebail_phase_scale_key(phase.phase_id())?)
2141 .copied()
2142 .unwrap_or(phase.scale());
2143 let mut positions = phase.two_theta_deg.clone();
2144 for (index, reflection_id) in phase.reflection_ids.iter().enumerate() {
2145 if let Some(value) = values.get(&lebail_reflection_position_key(
2146 phase.phase_id(),
2147 reflection_id,
2148 )?) {
2149 positions[index] = *value;
2150 }
2151 }
2152 let mut updated = phase.replace_scale_and_positions(scale, positions)?;
2153 let lattice_values = values
2154 .iter()
2155 .filter(|(key, _)| key.module() == "lattice" && key.owner_id() == phase.phase_id())
2156 .collect::<Vec<_>>();
2157 if !lattice_values.is_empty() {
2158 let cell = phase.cell.ok_or_else(|| {
2159 invalid_phase("lattice parameters require a bounded reflection domain")
2160 })?;
2161 let domain = phase.reflection_domain.as_ref().ok_or_else(|| {
2162 invalid_phase("lattice parameters require a bounded reflection domain")
2163 })?;
2164 let mut independent = domain
2165 .parameterization()
2166 .values_from_cell(cell)
2167 .map_err(LeBailError::Lattice)?;
2168 for (key, value) in lattice_values {
2169 let index = domain
2170 .parameterization()
2171 .parameter_names()
2172 .iter()
2173 .position(|name| name == key.name())
2174 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2175 independent[index] = *value;
2176 }
2177 let cell = domain
2178 .parameterization()
2179 .to_cell(&independent)
2180 .map_err(LeBailError::Lattice)?;
2181 updated =
2182 updated.replace_cell_geometry(cell, updated_instrument.wavelength_angstrom)?;
2183 }
2184 updated_phases.push(updated);
2185 }
2186 Ok((updated_instrument, updated_phases))
2187}
2188
2189fn regenerate_accepted_domains(
2190 phases: Vec<LeBailPhase>,
2191) -> Result<(Vec<LeBailPhase>, Vec<String>, bool), LeBailError> {
2192 let mut updated = Vec::with_capacity(phases.len());
2193 let mut warnings = Vec::new();
2194 let mut topology_changed = false;
2195 for phase in phases {
2196 let Some(domain) = phase.reflection_domain.as_ref() else {
2197 updated.push(phase);
2198 continue;
2199 };
2200 let cell = phase.cell.ok_or(LeBailError::InternalInvariant)?;
2201 let previous = phase
2202 .reflection_ids
2203 .iter()
2204 .cloned()
2205 .zip(phase.integrated_intensity.iter().copied())
2206 .collect::<BTreeMap<_, _>>();
2207 let generated = domain
2208 .generate(cell, Some(&previous))
2209 .map_err(LeBailError::Lattice)?;
2210 let changed = generated.reflection_ids != phase.reflection_ids;
2211 topology_changed |= changed;
2212 if !generated.added_reflection_ids.is_empty()
2213 || !generated.removed_reflection_ids.is_empty()
2214 {
2215 warnings.push(format!(
2216 "phase {} reflection domain regenerated: {} added, {} removed",
2217 phase.phase_id(),
2218 generated.added_reflection_ids.len(),
2219 generated.removed_reflection_ids.len()
2220 ));
2221 }
2222 updated.push(phase.replace_generated_domain(cell, generated)?);
2223 }
2224 Ok((updated, warnings, topology_changed))
2225}
2226
2227#[allow(clippy::too_many_arguments)]
2228fn covariance(
2229 pattern: &PatternRecord,
2230 calculation: &LeBailCalculation,
2231 instrument: ConstantWavelengthInstrument,
2232 phases: &[LeBailPhase],
2233 parameters: Option<&ParameterSet>,
2234 constraints: &[Constraint],
2235 use_uncertainty: bool,
2236 reduced_chi_square: f64,
2237) -> Result<Option<CovarianceMatrix>, LeBailError> {
2238 let Some(parameters) = parameters else {
2239 return Ok(None);
2240 };
2241 let transform = ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2242 .map_err(LeBailError::Constraint)?;
2243 let free_count = transform.free_keys().len();
2244 if free_count == 0 {
2245 return Ok(Some(CovarianceMatrix {
2246 size: 0,
2247 values: Vec::new(),
2248 }));
2249 }
2250 let derivative = transform
2251 .derivative_matrix()
2252 .map_err(LeBailError::Constraint)?;
2253 for (row, spec) in parameters.specs().iter().enumerate() {
2254 if spec.key().module() == "phase"
2255 && spec.key().name() == "scale"
2256 && derivative
2257 .row(row)
2258 .is_some_and(|values| values.iter().any(|value| *value != 0.0))
2259 {
2260 return Ok(None);
2261 }
2262 }
2263 let physical = parameter_columns(calculation, parameters, instrument, phases)?;
2264 let chain = DMatrix::from_row_slice(derivative.rows, derivative.columns, &derivative.values);
2265 let jacobian = physical * chain;
2266 let included = pattern
2267 .mask
2268 .clone()
2269 .unwrap_or_else(|| vec![true; pattern.sample_count()]);
2270 let row_count = included.iter().filter(|value| **value).count();
2271 let mut selected = DMatrix::zeros(row_count, free_count);
2272 let mut row = 0;
2273 for sample in 0..pattern.sample_count() {
2274 if !included[sample] {
2275 continue;
2276 }
2277 let weight = if use_uncertainty {
2278 pattern
2279 .uncertainty
2280 .as_ref()
2281 .map_or(1.0, |values| values[sample].recip())
2282 } else {
2283 1.0
2284 };
2285 for column in 0..free_count {
2286 selected[(row, column)] = jacobian[(sample, column)] * weight;
2287 }
2288 row += 1;
2289 }
2290 let normal = selected.transpose() * selected;
2291 if matrix_rank(&normal) != free_count {
2292 return Ok(None);
2293 }
2294 let Some(mut inverse) = normal.try_inverse() else {
2295 return Ok(None);
2296 };
2297 let known_uncertainties = use_uncertainty && pattern.uncertainty.is_some();
2298 if !known_uncertainties && reduced_chi_square.is_finite() {
2299 inverse *= reduced_chi_square;
2300 }
2301 let mut values = Vec::with_capacity(free_count * free_count);
2302 for row in 0..free_count {
2303 for column in 0..free_count {
2304 values.push(inverse[(row, column)]);
2305 }
2306 }
2307 Ok(Some(CovarianceMatrix {
2308 size: free_count,
2309 values,
2310 }))
2311}
2312
2313fn free_parameter_count(
2314 parameters: Option<&ParameterSet>,
2315 constraints: &[Constraint],
2316) -> Result<usize, LeBailError> {
2317 parameters.map_or(Ok(0), |parameters| {
2318 ConstraintTransform::new(parameters.clone(), constraints.to_vec())
2319 .map(|transform| transform.free_keys().len())
2320 .map_err(LeBailError::Constraint)
2321 })
2322}
2323
2324fn matrix_rank(matrix: &DMatrix<f64>) -> usize {
2325 let singular = matrix.clone().svd(false, false).singular_values;
2326 let maximum = singular.iter().copied().fold(0.0_f64, f64::max);
2327 let tolerance = count_as_f64(matrix.nrows().max(matrix.ncols())) * f64::EPSILON * maximum;
2328 singular.iter().filter(|value| **value > tolerance).count()
2329}
2330
2331fn instrument_parameter(instrument: ConstantWavelengthInstrument, name: &str) -> Option<f64> {
2332 match name {
2333 "u_deg2" => Some(instrument.u_deg2),
2334 "v_deg2" => Some(instrument.v_deg2),
2335 "w_deg2" => Some(instrument.w_deg2),
2336 "x_deg" => Some(instrument.x_deg),
2337 "y_deg" => Some(instrument.y_deg),
2338 _ => None,
2339 }
2340}
2341
2342fn set_instrument_parameter(
2343 instrument: &mut ConstantWavelengthInstrument,
2344 name: &str,
2345 value: f64,
2346) -> Result<(), LeBailError> {
2347 match name {
2348 "u_deg2" => instrument.u_deg2 = value,
2349 "v_deg2" => instrument.v_deg2 = value,
2350 "w_deg2" => instrument.w_deg2 = value,
2351 "x_deg" => instrument.x_deg = value,
2352 "y_deg" => instrument.y_deg = value,
2353 _ => {
2354 return Err(LeBailError::UnsupportedParameter {
2355 label: format!("instrument[cw].{name}"),
2356 });
2357 }
2358 }
2359 Ok(())
2360}
2361
2362fn rank_deficient_groups(
2363 calculation: &LeBailCalculation,
2364 threshold: f64,
2365) -> Vec<CoincidentReflectionGroup> {
2366 let local = &calculation.accumulation.derivatives.local;
2367 let count = local.peak_count();
2368 let mut parents = (0..count).collect::<Vec<_>>();
2369 let norms = (0..count)
2370 .map(|reflection| {
2371 let begin = local.offsets[reflection];
2372 let end = local.offsets[reflection + 1];
2373 (begin..end)
2374 .map(|active| {
2375 let value = local.values[active * local.parameter_count];
2376 value * value
2377 })
2378 .sum::<f64>()
2379 .sqrt()
2380 })
2381 .collect::<Vec<_>>();
2382 for left in 0..count {
2383 let left_begin = local.offsets[left];
2384 let left_end = local.offsets[left + 1];
2385 let left_start = local.starts[left];
2386 let left_stop = left_start + left_end - left_begin;
2387 for right in left + 1..count {
2388 let right_begin = local.offsets[right];
2389 let right_end = local.offsets[right + 1];
2390 let right_start = local.starts[right];
2391 let right_stop = right_start + right_end - right_begin;
2392 let start = left_start.max(right_start);
2393 let stop = left_stop.min(right_stop);
2394 if start >= stop || norms[left] == 0.0 || norms[right] == 0.0 {
2395 continue;
2396 }
2397 let correlation = (start..stop)
2398 .map(|sample| {
2399 let left_active = left_begin + sample - left_start;
2400 let right_active = right_begin + sample - right_start;
2401 local.values[left_active * local.parameter_count]
2402 * local.values[right_active * local.parameter_count]
2403 })
2404 .sum::<f64>()
2405 / (norms[left] * norms[right]);
2406 if correlation >= threshold {
2407 union(&mut parents, left, right);
2408 }
2409 }
2410 }
2411 let mut grouped = std::collections::BTreeMap::<usize, Vec<usize>>::new();
2412 for reflection in 0..count {
2413 let root = root(&mut parents, reflection);
2414 grouped.entry(root).or_default().push(reflection);
2415 }
2416 grouped
2417 .into_values()
2418 .filter(|indices| indices.len() > 1)
2419 .map(|indices| {
2420 let first = indices
2421 .iter()
2422 .map(|index| local.starts[*index])
2423 .min()
2424 .unwrap_or(0);
2425 let last = indices
2426 .iter()
2427 .map(|index| {
2428 local.starts[*index] + local.offsets[*index + 1] - local.offsets[*index]
2429 })
2430 .max()
2431 .unwrap_or(first);
2432 let mut matrix = DMatrix::zeros(last - first, indices.len());
2433 for (column, reflection) in indices.iter().enumerate() {
2434 let begin = local.offsets[*reflection];
2435 let end = local.offsets[*reflection + 1];
2436 let start = local.starts[*reflection] - first;
2437 for active in begin..end {
2438 matrix[(start + active - begin, column)] =
2439 local.values[active * local.parameter_count];
2440 }
2441 }
2442 let singular_values = matrix.svd(false, false).singular_values;
2443 let maximum = singular_values.iter().copied().fold(0.0_f64, f64::max);
2444 let tolerance =
2445 count_as_f64((last - first).max(indices.len())) * f64::EPSILON * maximum;
2446 let rank = singular_values
2447 .iter()
2448 .filter(|value| **value > tolerance)
2449 .count();
2450 CoincidentReflectionGroup {
2451 reflection_keys: indices
2452 .iter()
2453 .map(|index| calculation.reflection_keys[*index].clone())
2454 .collect(),
2455 rank,
2456 }
2457 })
2458 .collect()
2459}
2460
2461fn root(parents: &mut [usize], mut index: usize) -> usize {
2462 while parents[index] != index {
2463 parents[index] = parents[parents[index]];
2464 index = parents[index];
2465 }
2466 index
2467}
2468
2469fn union(parents: &mut [usize], left: usize, right: usize) {
2470 let left_root = root(parents, left);
2471 let right_root = root(parents, right);
2472 if left_root != right_root {
2473 parents[right_root] = left_root;
2474 }
2475}
2476
2477fn bin_integration_weights(x: &[f64]) -> Vec<f64> {
2478 match x.len() {
2479 0 => Vec::new(),
2480 1 => vec![1.0],
2481 count => {
2482 let mut widths = vec![0.0; count];
2483 widths[0] = 0.5 * (x[1] - x[0]);
2484 widths[count - 1] = 0.5 * (x[count - 1] - x[count - 2]);
2485 for index in 1..count - 1 {
2486 widths[index] = 0.5 * (x[index + 1] - x[index - 1]);
2487 }
2488 widths
2489 }
2490 }
2491}
2492
2493fn replace_flat_intensities(
2494 phases: &[LeBailPhase],
2495 intensities: &[f64],
2496) -> Result<Vec<LeBailPhase>, LeBailError> {
2497 let expected = phases.iter().map(reflection_count).sum::<usize>();
2498 if intensities.len() != expected {
2499 return Err(LeBailError::IntensityShapeMismatch);
2500 }
2501 let mut offset = 0;
2502 phases
2503 .iter()
2504 .map(|phase| {
2505 let end = offset + reflection_count(phase);
2506 let updated = phase.replace_intensities(&intensities[offset..end]);
2507 offset = end;
2508 updated
2509 })
2510 .collect()
2511}
2512
2513fn flatten_intensities(phases: &[LeBailPhase]) -> Vec<f64> {
2514 phases
2515 .iter()
2516 .flat_map(|phase| phase.integrated_intensity.iter().copied())
2517 .collect()
2518}
2519
2520fn flatten_preserve_mask(phases: &[LeBailPhase]) -> Vec<bool> {
2521 phases
2522 .iter()
2523 .flat_map(|phase| {
2524 if phase.preserve_unobserved.is_empty() {
2525 vec![false; reflection_count(phase)]
2526 } else {
2527 phase.preserve_unobserved.clone()
2528 }
2529 })
2530 .collect()
2531}
2532
2533fn parameter_keys(parameters: &ParameterSet) -> Vec<&ParameterKey> {
2534 parameters.specs().iter().map(ParameterSpec::key).collect()
2535}
2536
2537fn validate_parameter_selection(
2538 phases: &[LeBailPhase],
2539 parameters: &ParameterSet,
2540) -> Result<(), LeBailError> {
2541 for spec in parameters.specs() {
2542 let key = spec.key();
2543 if key.module() == "lattice" {
2544 let phase = phases
2545 .iter()
2546 .find(|phase| phase.phase_id() == key.owner_id())
2547 .ok_or_else(|| LeBailError::UnsupportedParameter { label: key.label() })?;
2548 let Some(domain) = phase.reflection_domain.as_ref() else {
2549 return Err(invalid_phase(
2550 "lattice parameters require bounded dynamic phases",
2551 ));
2552 };
2553 if !domain
2554 .parameterization()
2555 .parameter_names()
2556 .iter()
2557 .any(|name| name == key.name())
2558 {
2559 return Err(LeBailError::UnsupportedParameter { label: key.label() });
2560 }
2561 } else if key.module() == "reflection" {
2562 let dynamic = phases.iter().any(|phase| {
2563 phase.reflection_domain.is_some()
2564 && key
2565 .owner_id()
2566 .strip_prefix(phase.phase_id())
2567 .is_some_and(|suffix| suffix.starts_with('/'))
2568 });
2569 if dynamic {
2570 return Err(invalid_phase(
2571 "independent reflection positions require fixed-topology phases",
2572 ));
2573 }
2574 }
2575 }
2576 Ok(())
2577}
2578
2579fn phases_restart_compatible(input: &[LeBailPhase], checkpoint: &[LeBailPhase]) -> bool {
2580 input.len() == checkpoint.len()
2581 && input.iter().zip(checkpoint).all(|(left, right)| {
2582 if left.phase_id() != right.phase_id() {
2583 return false;
2584 }
2585 match (&left.reflection_domain, &right.reflection_domain) {
2586 (None, None) => left.reflection_ids == right.reflection_ids,
2587 (Some(left_domain), Some(right_domain)) => left_domain == right_domain,
2588 _ => false,
2589 }
2590 })
2591}
2592
2593fn reflection_count(phase: &LeBailPhase) -> usize {
2594 phase.reflection_ids.len()
2595}
2596
2597fn reflection_count_of(phase: &LeBailPhase) -> usize {
2598 reflection_count(phase)
2599}
2600
2601fn validate_stable_label(name: &'static str, value: &str) -> Result<(), LeBailError> {
2602 if value.is_empty()
2603 || value.trim() != value
2604 || value.chars().any(char::is_control)
2605 || value.contains('/')
2606 {
2607 return Err(LeBailError::InvalidPhase {
2608 message: format!(
2609 "{name} must be non-empty, trimmed, and contain neither '/' nor control characters"
2610 ),
2611 });
2612 }
2613 Ok(())
2614}
2615
2616fn normal_stop_reason(error: &RuntimeError) -> Option<TerminationReason> {
2617 match error {
2618 RuntimeError::Stopped(stop) => Some(stop.reason),
2619 _ => None,
2620 }
2621}
2622
2623fn stop_reason_or_error(error: RuntimeError) -> Result<TerminationReason, LeBailError> {
2624 normal_stop_reason(&error).ok_or(LeBailError::Runtime(error))
2625}
2626
2627#[allow(clippy::cast_precision_loss)]
2628fn count_as_f64(value: usize) -> f64 {
2629 value as f64
2630}
2631
2632fn invalid_phase(message: &str) -> LeBailError {
2633 LeBailError::InvalidPhase {
2634 message: message.to_owned(),
2635 }
2636}
2637
2638fn invalid_options(message: &str) -> LeBailError {
2639 LeBailError::InvalidOptions {
2640 message: message.to_owned(),
2641 }
2642}
2643
2644#[derive(Debug)]
2646pub enum LeBailError {
2647 Pattern(DomainError),
2649 MissingObservations,
2651 Parameter(ParameterError),
2653 Constraint(ConstraintError),
2655 Lattice(LatticeError),
2657 UnsupportedParameter {
2659 label: String,
2661 },
2662 ParameterDomainOutsideBounds {
2664 label: String,
2666 },
2667 LinearSolve,
2669 InvalidPhase {
2671 message: String,
2673 },
2674 InvalidOptions {
2676 message: String,
2678 },
2679 InvalidCheckpoint {
2681 message: String,
2683 },
2684 IntensityShapeMismatch,
2686 PreserveMaskLengthMismatch,
2688 Grid(ProfileError),
2690 Calculation(CwContributionsError),
2692 Residual(ResidualError),
2694 Runtime(RuntimeError),
2696 Execution(ExecutionPolicyError),
2698 SizeOverflow,
2700 Profile {
2702 message: String,
2704 },
2705 InternalInvariant,
2707}
2708
2709impl Display for LeBailError {
2710 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
2711 match self {
2712 Self::Pattern(error) => Display::fmt(error, formatter),
2713 Self::MissingObservations => {
2714 formatter.write_str("observed_y is required for Le Bail extraction")
2715 }
2716 Self::Parameter(error) => Display::fmt(error, formatter),
2717 Self::Constraint(error) => Display::fmt(error, formatter),
2718 Self::Lattice(error) => Display::fmt(error, formatter),
2719 Self::UnsupportedParameter { label } => {
2720 write!(formatter, "unsupported Le Bail parameter {label}")
2721 }
2722 Self::ParameterDomainOutsideBounds { label } => {
2723 write!(
2724 formatter,
2725 "domain value for {label} lies outside its bounds"
2726 )
2727 }
2728 Self::LinearSolve => formatter.write_str("profile least-squares solve failed"),
2729 Self::InvalidPhase { message }
2730 | Self::InvalidOptions { message }
2731 | Self::InvalidCheckpoint { message }
2732 | Self::Profile { message } => formatter.write_str(message),
2733 Self::IntensityShapeMismatch => {
2734 formatter.write_str("current intensities must match the reflection count")
2735 }
2736 Self::PreserveMaskLengthMismatch => {
2737 formatter.write_str("preserve_unobserved must match the reflection count")
2738 }
2739 Self::Grid(error) => Display::fmt(error, formatter),
2740 Self::Calculation(error) => Display::fmt(error, formatter),
2741 Self::Residual(error) => Display::fmt(error, formatter),
2742 Self::Runtime(error) => Display::fmt(error, formatter),
2743 Self::Execution(error) => Display::fmt(error, formatter),
2744 Self::SizeOverflow => formatter.write_str("Le Bail workflow size or budget overflowed"),
2745 Self::InternalInvariant => {
2746 formatter.write_str("internal Le Bail workflow state is inconsistent")
2747 }
2748 }
2749 }
2750}
2751
2752impl Error for LeBailError {
2753 fn source(&self) -> Option<&(dyn Error + 'static)> {
2754 match self {
2755 Self::Pattern(error) => Some(error),
2756 Self::Parameter(error) => Some(error),
2757 Self::Constraint(error) => Some(error),
2758 Self::Lattice(error) => Some(error),
2759 Self::Grid(error) => Some(error),
2760 Self::Calculation(error) => Some(error),
2761 Self::Residual(error) => Some(error),
2762 Self::Runtime(error) => Some(error),
2763 Self::Execution(error) => Some(error),
2764 Self::MissingObservations
2765 | Self::UnsupportedParameter { .. }
2766 | Self::ParameterDomainOutsideBounds { .. }
2767 | Self::LinearSolve
2768 | Self::InvalidPhase { .. }
2769 | Self::InvalidOptions { .. }
2770 | Self::InvalidCheckpoint { .. }
2771 | Self::IntensityShapeMismatch
2772 | Self::PreserveMaskLengthMismatch
2773 | Self::SizeOverflow
2774 | Self::Profile { .. }
2775 | Self::InternalInvariant => None,
2776 }
2777 }
2778}