1use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use phasesmith_core::{ConstantWavelengthInstrument, CwProfileParameters};
7use phasesmith_execution::ExecutionPolicy;
8use phasesmith_model::PatternRecord;
9
10use crate::{
11 CovarianceMatrix, LeBailError, LeBailInput, LeBailOptions, LeBailPhase, LeBailResult,
12 build_lebail_parameter_set, build_lebail_parameter_set_with_lattice, refine_lebail,
13};
14
15const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
16const W_PARAMETERS: [&str; 1] = ["w_deg2"];
17const UVW_PARAMETERS: [&str; 3] = ["u_deg2", "v_deg2", "w_deg2"];
18const UVWXY_PARAMETERS: [&str; 5] = ["u_deg2", "v_deg2", "w_deg2", "x_deg", "y_deg"];
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum ProfileEstimationMode {
23 WOnly,
25 Uvw,
27 Uvwxy,
29 Automatic,
31}
32
33impl ProfileEstimationMode {
34 #[must_use]
36 pub const fn as_str(self) -> &'static str {
37 match self {
38 Self::WOnly => "w_only",
39 Self::Uvw => "uvw",
40 Self::Uvwxy => "uvwxy",
41 Self::Automatic => "automatic",
42 }
43 }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ProfileEstimationStageKind {
49 LatticeAlignment,
51 W,
53 Uvw,
55 Uvwxy,
57}
58
59impl ProfileEstimationStageKind {
60 #[must_use]
62 pub const fn as_str(self) -> &'static str {
63 match self {
64 Self::LatticeAlignment => "lattice_alignment",
65 Self::W => "w",
66 Self::Uvw => "uvw",
67 Self::Uvwxy => "uvwxy",
68 }
69 }
70}
71
72#[derive(Clone, Debug, PartialEq)]
74pub struct ProfileEstimationInput {
75 pub pattern: PatternRecord,
77 pub instrument: ConstantWavelengthInstrument,
79 pub phase: LeBailPhase,
81}
82
83impl ProfileEstimationInput {
84 pub fn new(
90 pattern: PatternRecord,
91 instrument: ConstantWavelengthInstrument,
92 phase: LeBailPhase,
93 ) -> Result<Self, ProfileEstimationError> {
94 LeBailInput::new(pattern.clone(), instrument, vec![phase.clone()])?;
95 Ok(Self {
96 pattern,
97 instrument,
98 phase,
99 })
100 }
101}
102
103#[derive(Clone, Debug, PartialEq)]
105pub struct ProfileEstimationOptions {
106 pub mode: ProfileEstimationMode,
108 pub align_lattice: bool,
110 pub minimum_relative_rwp_improvement: f64,
112 pub minimum_absolute_rwp_improvement: f64,
114 pub maximum_absolute_correlation: f64,
116 pub lebail: LeBailOptions,
118}
119
120impl ProfileEstimationOptions {
121 pub fn new(
127 mode: ProfileEstimationMode,
128 align_lattice: bool,
129 minimum_relative_rwp_improvement: f64,
130 minimum_absolute_rwp_improvement: f64,
131 maximum_absolute_correlation: f64,
132 lebail: LeBailOptions,
133 ) -> Result<Self, ProfileEstimationError> {
134 let options = Self {
135 mode,
136 align_lattice,
137 minimum_relative_rwp_improvement,
138 minimum_absolute_rwp_improvement,
139 maximum_absolute_correlation,
140 lebail,
141 };
142 options.validate()?;
143 Ok(options)
144 }
145
146 pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, ProfileEstimationError> {
156 Self::new(
157 ProfileEstimationMode::Automatic,
158 false,
159 0.002,
160 1.0e-5,
161 0.98,
162 LeBailOptions::scripting_defaults(execution)?,
163 )
164 }
165
166 fn validate(&self) -> Result<(), ProfileEstimationError> {
167 if !self.minimum_relative_rwp_improvement.is_finite()
168 || self.minimum_relative_rwp_improvement < 0.0
169 {
170 return Err(ProfileEstimationError::InvalidOptions {
171 message: "minimum_relative_rwp_improvement must be non-negative and finite"
172 .to_owned(),
173 });
174 }
175 if !self.minimum_absolute_rwp_improvement.is_finite()
176 || self.minimum_absolute_rwp_improvement < 0.0
177 {
178 return Err(ProfileEstimationError::InvalidOptions {
179 message: "minimum_absolute_rwp_improvement must be non-negative and finite"
180 .to_owned(),
181 });
182 }
183 if !self.maximum_absolute_correlation.is_finite()
184 || !(0.0..1.0).contains(&self.maximum_absolute_correlation)
185 {
186 return Err(ProfileEstimationError::InvalidOptions {
187 message: "maximum_absolute_correlation must lie in [0, 1)".to_owned(),
188 });
189 }
190 Ok(())
191 }
192}
193
194#[derive(Clone, Debug, PartialEq)]
196pub struct ProfileEstimationStage {
197 pub kind: ProfileEstimationStageKind,
199 pub instrument_parameters: Vec<String>,
201 pub accepted: bool,
203 pub decision: String,
205 pub rwp: f64,
207 pub instrument: ConstantWavelengthInstrument,
209 pub maximum_absolute_correlation: Option<f64>,
211}
212
213#[derive(Clone, Debug, PartialEq)]
215pub struct ProfileEstimationResult {
216 pub instrument: ConstantWavelengthInstrument,
218 pub active_parameters: Vec<String>,
220 pub lebail: LeBailResult,
222 pub stages: Vec<ProfileEstimationStage>,
224 pub warnings: Vec<String>,
226}
227
228pub fn starting_profile_from_fwhm(
237 wavelength_angstrom: f64,
238 fwhm_deg: f64,
239) -> Result<ConstantWavelengthInstrument, ProfileEstimationError> {
240 if !wavelength_angstrom.is_finite()
241 || wavelength_angstrom <= 0.0
242 || !fwhm_deg.is_finite()
243 || fwhm_deg <= 0.0
244 {
245 return Err(ProfileEstimationError::InvalidStartingFwhm);
246 }
247 Ok(ConstantWavelengthInstrument {
248 wavelength_angstrom,
249 u_deg2: 0.0,
250 v_deg2: 0.0,
251 w_deg2: (fwhm_deg / GAUSSIAN_FWHM_PER_SIGMA).powi(2),
252 x_deg: 0.0,
253 y_deg: 0.0,
254 })
255}
256
257pub fn estimate_effective_profile(
270 input: &ProfileEstimationInput,
271 options: &ProfileEstimationOptions,
272) -> Result<ProfileEstimationResult, ProfileEstimationError> {
273 options.validate()?;
274 let wavelength_bits = input.instrument.wavelength_angstrom.to_bits();
275 let mut instrument = input.instrument;
276 let mut phases = vec![input.phase.clone()];
277 let mut stages = Vec::new();
278
279 if options.align_lattice {
280 let aligned = align_lattice_stage(input, instrument, phases, options, wavelength_bits)?;
281 instrument = aligned.0;
282 phases = aligned.1;
283 stages.push(aligned.2);
284 }
285
286 let mut accepted = fit_profile_stage(
287 &input.pattern,
288 instrument,
289 phases,
290 &W_PARAMETERS,
291 &options.lebail,
292 )?;
293 ensure_fixed_wavelength(wavelength_bits, accepted.instrument)?;
294 validate_profile_domain(&input.pattern, &accepted)?;
295 stages.push(stage_record(
296 ProfileEstimationStageKind::W,
297 &W_PARAMETERS,
298 &accepted,
299 true,
300 "accepted required baseline model".to_owned(),
301 ));
302 let mut active = W_PARAMETERS.iter().map(ToString::to_string).collect();
303
304 if options.mode != ProfileEstimationMode::WOnly {
305 let uvw = fit_profile_stage(
306 &input.pattern,
307 accepted.instrument,
308 accepted.phases.clone(),
309 &UVW_PARAMETERS,
310 &options.lebail,
311 )?;
312 ensure_fixed_wavelength(wavelength_bits, uvw.instrument)?;
313 validate_profile_domain(&input.pattern, &uvw)?;
314 let (accept, decision) = candidate_decision(&accepted, &uvw, options, 3);
315 let forced = options.mode != ProfileEstimationMode::Automatic;
316 let accepted_stage = forced || accept;
317 stages.push(stage_record(
318 ProfileEstimationStageKind::Uvw,
319 &UVW_PARAMETERS,
320 &uvw,
321 accepted_stage,
322 if forced {
323 "accepted because UVW was explicitly requested".to_owned()
324 } else {
325 decision
326 },
327 ));
328 if accepted_stage {
329 accepted = uvw;
330 active = UVW_PARAMETERS.iter().map(ToString::to_string).collect();
331 } else {
332 return finish_result(accepted, active, stages, wavelength_bits);
333 }
334 }
335
336 if options.mode == ProfileEstimationMode::Uvwxy
337 || options.mode == ProfileEstimationMode::Automatic
338 {
339 let uvwxy = fit_profile_stage(
340 &input.pattern,
341 accepted.instrument,
342 accepted.phases.clone(),
343 &UVWXY_PARAMETERS,
344 &options.lebail,
345 )?;
346 ensure_fixed_wavelength(wavelength_bits, uvwxy.instrument)?;
347 validate_profile_domain(&input.pattern, &uvwxy)?;
348 let (accept, decision) = candidate_decision(&accepted, &uvwxy, options, 5);
349 let forced = options.mode == ProfileEstimationMode::Uvwxy;
350 let accepted_stage = forced || accept;
351 stages.push(stage_record(
352 ProfileEstimationStageKind::Uvwxy,
353 &UVWXY_PARAMETERS,
354 &uvwxy,
355 accepted_stage,
356 if forced {
357 "accepted because UVWXY was explicitly requested".to_owned()
358 } else {
359 decision
360 },
361 ));
362 if accepted_stage {
363 accepted = uvwxy;
364 active = UVWXY_PARAMETERS.iter().map(ToString::to_string).collect();
365 }
366 }
367
368 finish_result(accepted, active, stages, wavelength_bits)
369}
370
371fn align_lattice_stage(
372 input: &ProfileEstimationInput,
373 instrument: ConstantWavelengthInstrument,
374 phases: Vec<LeBailPhase>,
375 options: &ProfileEstimationOptions,
376 wavelength_bits: u64,
377) -> Result<
378 (
379 ConstantWavelengthInstrument,
380 Vec<LeBailPhase>,
381 ProfileEstimationStage,
382 ),
383 ProfileEstimationError,
384> {
385 if phases[0].reflection_domain().is_none() {
386 return Err(ProfileEstimationError::LatticeAlignmentRequiresDynamicPhase);
387 }
388 let parameters =
389 build_lebail_parameter_set_with_lattice(instrument, &phases, &[], false, false, true)?;
390 let result = run_stage(
391 &input.pattern,
392 instrument,
393 phases,
394 parameters,
395 &options.lebail,
396 )?;
397 ensure_fixed_wavelength(wavelength_bits, result.instrument)?;
398 let stage = ProfileEstimationStage {
399 kind: ProfileEstimationStageKind::LatticeAlignment,
400 instrument_parameters: Vec::new(),
401 accepted: true,
402 decision: "accepted bounded nuisance lattice alignment".to_owned(),
403 rwp: result.metrics.rwp,
404 instrument: result.instrument,
405 maximum_absolute_correlation: covariance_maximum_correlation(result.covariance.as_ref()),
406 };
407 Ok((result.instrument, result.phases, stage))
408}
409
410fn fit_profile_stage(
411 pattern: &PatternRecord,
412 instrument: ConstantWavelengthInstrument,
413 phases: Vec<LeBailPhase>,
414 names: &[&str],
415 options: &LeBailOptions,
416) -> Result<LeBailResult, ProfileEstimationError> {
417 let parameters = build_lebail_parameter_set(instrument, &phases, names, false, false)?;
418 run_stage(pattern, instrument, phases, parameters, options)
419}
420
421fn run_stage(
422 pattern: &PatternRecord,
423 instrument: ConstantWavelengthInstrument,
424 phases: Vec<LeBailPhase>,
425 parameters: crate::ParameterSet,
426 options: &LeBailOptions,
427) -> Result<LeBailResult, ProfileEstimationError> {
428 let request = LeBailInput::new_with_parameters(
429 pattern.clone(),
430 instrument,
431 phases,
432 parameters,
433 Vec::new(),
434 )?;
435 refine_lebail(&request, options, None).map_err(ProfileEstimationError::LeBail)
436}
437
438fn candidate_decision(
439 baseline: &LeBailResult,
440 candidate: &LeBailResult,
441 options: &ProfileEstimationOptions,
442 expected_parameters: usize,
443) -> (bool, String) {
444 let denominator = baseline.metrics.rwp.abs().max(f64::MIN_POSITIVE);
445 let absolute_improvement = baseline.metrics.rwp - candidate.metrics.rwp;
446 let improvement = absolute_improvement / denominator;
447 if absolute_improvement < options.minimum_absolute_rwp_improvement {
448 return (
449 false,
450 format!(
451 "rejected: absolute Rwp improvement {absolute_improvement:.6} is below {:.6}",
452 options.minimum_absolute_rwp_improvement
453 ),
454 );
455 }
456 if improvement < options.minimum_relative_rwp_improvement {
457 return (
458 false,
459 format!(
460 "rejected: relative Rwp improvement {improvement:.6} is below {:.6}",
461 options.minimum_relative_rwp_improvement
462 ),
463 );
464 }
465 let Some(covariance) = candidate.covariance.as_ref() else {
466 return (
467 false,
468 "rejected: candidate covariance is not identifiable".to_owned(),
469 );
470 };
471 if covariance.size != expected_parameters {
472 return (
473 false,
474 "rejected: candidate covariance dimension is inconsistent".to_owned(),
475 );
476 }
477 let Some(correlation) = covariance_maximum_correlation(Some(covariance)) else {
478 return (
479 false,
480 "rejected: candidate covariance has a non-positive variance".to_owned(),
481 );
482 };
483 if correlation > options.maximum_absolute_correlation {
484 return (
485 false,
486 format!(
487 "rejected: maximum absolute correlation {correlation:.6} exceeds {:.6}",
488 options.maximum_absolute_correlation
489 ),
490 );
491 }
492 (
493 true,
494 format!(
495 "accepted: relative Rwp improvement {improvement:.6}, maximum absolute correlation {correlation:.6}"
496 ),
497 )
498}
499
500fn covariance_maximum_correlation(covariance: Option<&CovarianceMatrix>) -> Option<f64> {
501 let covariance = covariance?;
502 if covariance.size == 0 {
503 return Some(0.0);
504 }
505 let mut maximum = 0.0_f64;
506 for row in 0..covariance.size {
507 let row_variance = covariance.values[row * covariance.size + row];
508 if !row_variance.is_finite() || row_variance <= 0.0 {
509 return None;
510 }
511 for column in 0..row {
512 let column_variance = covariance.values[column * covariance.size + column];
513 if !column_variance.is_finite() || column_variance <= 0.0 {
514 return None;
515 }
516 let correlation = covariance.values[row * covariance.size + column].abs()
517 / (row_variance * column_variance).sqrt();
518 if !correlation.is_finite() {
519 return None;
520 }
521 maximum = maximum.max(correlation);
522 }
523 }
524 Some(maximum)
525}
526
527fn stage_record(
528 kind: ProfileEstimationStageKind,
529 parameters: &[&str],
530 result: &LeBailResult,
531 accepted: bool,
532 decision: String,
533) -> ProfileEstimationStage {
534 ProfileEstimationStage {
535 kind,
536 instrument_parameters: parameters.iter().map(ToString::to_string).collect(),
537 accepted,
538 decision,
539 rwp: result.metrics.rwp,
540 instrument: result.instrument,
541 maximum_absolute_correlation: covariance_maximum_correlation(result.covariance.as_ref()),
542 }
543}
544
545fn validate_profile_domain(
546 pattern: &PatternRecord,
547 result: &LeBailResult,
548) -> Result<(), ProfileEstimationError> {
549 let mut angles = result
550 .phases
551 .iter()
552 .flat_map(|phase| phase.two_theta_deg().iter().copied())
553 .collect::<Vec<_>>();
554 if let (Some(first), Some(last)) = (pattern.x_deg.first(), pattern.x_deg.last()) {
555 if *first > 0.0 && *first < 180.0 {
556 angles.push(*first);
557 }
558 if *last > 0.0 && *last < 180.0 {
559 angles.push(*last);
560 }
561 }
562 for angle in angles {
563 CwProfileParameters::from_instrument(angle, result.instrument).map_err(|error| {
564 ProfileEstimationError::ProfileOutsideDomain {
565 angle_deg: angle,
566 message: error.to_string(),
567 }
568 })?;
569 }
570 Ok(())
571}
572
573fn ensure_fixed_wavelength(
574 expected_bits: u64,
575 instrument: ConstantWavelengthInstrument,
576) -> Result<(), ProfileEstimationError> {
577 if instrument.wavelength_angstrom.to_bits() != expected_bits {
578 return Err(ProfileEstimationError::WavelengthChanged);
579 }
580 Ok(())
581}
582
583fn finish_result(
584 accepted: LeBailResult,
585 active_parameters: Vec<String>,
586 stages: Vec<ProfileEstimationStage>,
587 wavelength_bits: u64,
588) -> Result<ProfileEstimationResult, ProfileEstimationError> {
589 ensure_fixed_wavelength(wavelength_bits, accepted.instrument)?;
590 Ok(ProfileEstimationResult {
591 instrument: accepted.instrument,
592 active_parameters,
593 lebail: accepted,
594 stages,
595 warnings: vec![
596 "effective profile may include crystallite-size, microstrain, pressure-gradient, and other sample broadening"
597 .to_owned(),
598 "mask unidentified impurity peaks or excluded regions before using this estimate"
599 .to_owned(),
600 ],
601 })
602}
603
604#[derive(Debug)]
606pub enum ProfileEstimationError {
607 InvalidOptions {
609 message: String,
611 },
612 InvalidStartingFwhm,
614 LatticeAlignmentRequiresDynamicPhase,
616 WavelengthChanged,
618 ProfileOutsideDomain {
620 angle_deg: f64,
622 message: String,
624 },
625 LeBail(LeBailError),
627}
628
629impl Display for ProfileEstimationError {
630 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
631 match self {
632 Self::InvalidOptions { message } => write!(formatter, "invalid options: {message}"),
633 Self::InvalidStartingFwhm => write!(
634 formatter,
635 "starting wavelength and approximate FWHM must be positive and finite"
636 ),
637 Self::LatticeAlignmentRequiresDynamicPhase => write!(
638 formatter,
639 "lattice alignment requires a phase with a bounded lattice reflection domain"
640 ),
641 Self::WavelengthChanged => {
642 write!(formatter, "fixed wavelength changed during estimation")
643 }
644 Self::ProfileOutsideDomain { angle_deg, message } => write!(
645 formatter,
646 "fitted profile is invalid at {angle_deg} degrees 2theta: {message}"
647 ),
648 Self::LeBail(error) => Display::fmt(error, formatter),
649 }
650 }
651}
652
653impl Error for ProfileEstimationError {
654 fn source(&self) -> Option<&(dyn Error + 'static)> {
655 match self {
656 Self::LeBail(error) => Some(error),
657 _ => None,
658 }
659 }
660}
661
662impl From<LeBailError> for ProfileEstimationError {
663 fn from(value: LeBailError) -> Self {
664 Self::LeBail(value)
665 }
666}