1use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::profile::{
7 Accumulation, DenseJacobian, GridView, PatternDerivatives, ProfileError, SupportJacobian,
8 SupportPolicy, zeroed_f64_vec,
9};
10use crate::tch::{TchShape, TchWidths};
11
12const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
13const DEGREE_HALF_ANGLE_TO_RADIAN: f64 = std::f64::consts::PI / 360.0;
14const LOCAL_PARAMETER_COUNT: usize = 2;
15const GLOBAL_PARAMETER_COUNT: usize = 5;
16
17#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct ConstantWavelengthInstrument {
20 pub wavelength_angstrom: f64,
22 pub u_deg2: f64,
24 pub v_deg2: f64,
26 pub w_deg2: f64,
28 pub x_deg: f64,
30 pub y_deg: f64,
32}
33
34impl ConstantWavelengthInstrument {
35 pub fn validate(self) -> Result<(), CwError> {
42 if !self.wavelength_angstrom.is_finite() || self.wavelength_angstrom <= 0.0 {
43 return Err(CwError::InvalidWavelength);
44 }
45 for (name, value) in [
46 ("U", self.u_deg2),
47 ("V", self.v_deg2),
48 ("W", self.w_deg2),
49 ("X", self.x_deg),
50 ("Y", self.y_deg),
51 ] {
52 if !value.is_finite() {
53 return Err(CwError::NonFiniteInstrumentParameter { name });
54 }
55 }
56 Ok(())
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct CwProfileParameters {
63 pub gaussian_variance_deg2: f64,
65 pub gaussian_fwhm_deg: f64,
67 pub lorentzian_fwhm_deg: f64,
69 pub tch: TchShape,
71 pub d_gaussian_fwhm_d_instrument: [f64; GLOBAL_PARAMETER_COUNT],
73 pub d_lorentzian_fwhm_d_instrument: [f64; GLOBAL_PARAMETER_COUNT],
75 pub d_gaussian_fwhm_d_two_theta: f64,
77 pub d_lorentzian_fwhm_d_two_theta: f64,
79}
80
81impl CwProfileParameters {
82 pub fn from_instrument(
89 two_theta_deg: f64,
90 instrument: ConstantWavelengthInstrument,
91 ) -> Result<Self, CwError> {
92 instrument.validate()?;
93 Self::from_validated_instrument(two_theta_deg, instrument)
94 }
95
96 pub(crate) fn from_validated_instrument(
97 two_theta_deg: f64,
98 instrument: ConstantWavelengthInstrument,
99 ) -> Result<Self, CwError> {
100 validate_two_theta(two_theta_deg)?;
101 let theta = two_theta_deg * DEGREE_HALF_ANGLE_TO_RADIAN;
102 let tangent = theta.tan();
103 let secant = theta.cos().recip();
104 let tangent_2 = tangent * tangent;
105 let gaussian_variance_deg2 =
106 instrument.u_deg2 * tangent_2 + instrument.v_deg2 * tangent + instrument.w_deg2;
107 if !gaussian_variance_deg2.is_finite() || gaussian_variance_deg2 <= 0.0 {
108 return Err(CwError::NonPositiveGaussianVariance);
109 }
110 let gaussian_sigma = gaussian_variance_deg2.sqrt();
111 let gaussian_fwhm_deg = GAUSSIAN_FWHM_PER_SIGMA * gaussian_sigma;
112 let lorentzian_fwhm_deg = instrument.x_deg * secant + instrument.y_deg * tangent;
113 if !lorentzian_fwhm_deg.is_finite() || lorentzian_fwhm_deg < 0.0 {
114 return Err(CwError::NegativeLorentzianFwhm);
115 }
116 let tch = TchShape::from_component_fwhm(TchWidths {
117 gaussian_fwhm: gaussian_fwhm_deg,
118 lorentzian_fwhm: lorentzian_fwhm_deg,
119 })
120 .map_err(|_| CwError::InvalidTchTransform)?;
121
122 let d_gaussian_d_variance = GAUSSIAN_FWHM_PER_SIGMA / (2.0 * gaussian_sigma);
123 let d_gaussian_fwhm_d_instrument = [
124 d_gaussian_d_variance * tangent_2,
125 d_gaussian_d_variance * tangent,
126 d_gaussian_d_variance,
127 0.0,
128 0.0,
129 ];
130 let d_lorentzian_fwhm_d_instrument = [0.0, 0.0, 0.0, secant, tangent];
131 let d_tangent_d_two_theta = DEGREE_HALF_ANGLE_TO_RADIAN * secant * secant;
132 let d_secant_d_two_theta = DEGREE_HALF_ANGLE_TO_RADIAN * secant * tangent;
133 let d_variance_d_two_theta =
134 (2.0 * instrument.u_deg2 * tangent + instrument.v_deg2) * d_tangent_d_two_theta;
135 let d_gaussian_fwhm_d_two_theta = d_gaussian_d_variance * d_variance_d_two_theta;
136 let d_lorentzian_fwhm_d_two_theta =
137 instrument.x_deg * d_secant_d_two_theta + instrument.y_deg * d_tangent_d_two_theta;
138
139 Ok(Self {
140 gaussian_variance_deg2,
141 gaussian_fwhm_deg,
142 lorentzian_fwhm_deg,
143 tch,
144 d_gaussian_fwhm_d_instrument,
145 d_lorentzian_fwhm_d_instrument,
146 d_gaussian_fwhm_d_two_theta,
147 d_lorentzian_fwhm_d_two_theta,
148 })
149 }
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum CwError {
155 InvalidWavelength,
157 NonFiniteInstrumentParameter {
159 name: &'static str,
161 },
162 InvalidTwoTheta,
164 NonPositiveGaussianVariance,
166 NegativeLorentzianFwhm,
168 InvalidTchTransform,
170}
171
172impl Display for CwError {
173 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
174 match self {
175 Self::InvalidWavelength => write!(formatter, "wavelength must be positive and finite"),
176 Self::NonFiniteInstrumentParameter { name } => {
177 write!(formatter, "instrument parameter {name} must be finite")
178 }
179 Self::InvalidTwoTheta => {
180 write!(
181 formatter,
182 "two_theta must be finite and within (0, 180) degrees"
183 )
184 }
185 Self::NonPositiveGaussianVariance => {
186 write!(
187 formatter,
188 "derived Gaussian variance must be positive and finite"
189 )
190 }
191 Self::NegativeLorentzianFwhm => {
192 write!(
193 formatter,
194 "derived Lorentzian FWHM must be non-negative and finite"
195 )
196 }
197 Self::InvalidTchTransform => write!(formatter, "derived widths fail the TCH transform"),
198 }
199 }
200}
201
202impl Error for CwError {}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum CwBatchError {
207 ReflectionLengthMismatch,
209 NonFiniteIntensity {
211 reflection: usize,
213 },
214 InvalidInstrument {
216 reason: CwError,
218 },
219 InvalidReflection {
221 reflection: usize,
223 reason: CwError,
225 },
226 Accumulation {
228 reason: ProfileError,
230 },
231}
232
233impl Display for CwBatchError {
234 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::ReflectionLengthMismatch => write!(
237 formatter,
238 "two_theta positions and integrated intensities must have equal length"
239 ),
240 Self::NonFiniteIntensity { reflection } => {
241 write!(
242 formatter,
243 "reflection {reflection} intensity must be finite"
244 )
245 }
246 Self::InvalidInstrument { reason } => {
247 write!(
248 formatter,
249 "invalid constant-wavelength instrument: {reason}"
250 )
251 }
252 Self::InvalidReflection { reflection, reason } => write!(
253 formatter,
254 "constant-wavelength reflection {reflection} is invalid: {reason}"
255 ),
256 Self::Accumulation { reason } => Display::fmt(reason, formatter),
257 }
258 }
259}
260
261impl Error for CwBatchError {}
262
263impl From<ProfileError> for CwBatchError {
264 fn from(reason: ProfileError) -> Self {
265 Self::Accumulation { reason }
266 }
267}
268
269#[derive(Clone, Copy, Debug)]
271pub struct CwReflectionBatchView<'a> {
272 two_theta_deg: &'a [f64],
273 intensities: &'a [f64],
274}
275
276impl<'a> CwReflectionBatchView<'a> {
277 pub fn new(two_theta_deg: &'a [f64], intensities: &'a [f64]) -> Result<Self, CwBatchError> {
284 if two_theta_deg.len() != intensities.len() {
285 return Err(CwBatchError::ReflectionLengthMismatch);
286 }
287 for reflection in 0..two_theta_deg.len() {
288 validate_two_theta(two_theta_deg[reflection])
289 .map_err(|reason| CwBatchError::InvalidReflection { reflection, reason })?;
290 if !intensities[reflection].is_finite() {
291 return Err(CwBatchError::NonFiniteIntensity { reflection });
292 }
293 }
294 Ok(Self {
295 two_theta_deg,
296 intensities,
297 })
298 }
299
300 #[must_use]
302 pub const fn len(self) -> usize {
303 self.two_theta_deg.len()
304 }
305
306 #[must_use]
308 pub const fn is_empty(self) -> bool {
309 self.two_theta_deg.is_empty()
310 }
311
312 pub(crate) const fn position(self, reflection: usize) -> f64 {
313 self.two_theta_deg[reflection]
314 }
315
316 pub(crate) const fn intensity(self, reflection: usize) -> f64 {
317 self.intensities[reflection]
318 }
319}
320
321pub fn accumulate_cw_batch(
331 grid: GridView<'_>,
332 reflections: CwReflectionBatchView<'_>,
333 instrument: ConstantWavelengthInstrument,
334 support: SupportPolicy,
335) -> Result<Accumulation, CwBatchError> {
336 support.validate()?;
337 instrument
338 .validate()
339 .map_err(|reason| CwBatchError::InvalidInstrument { reason })?;
340 let x = grid.as_slice();
341 let reflection_count = reflections.len();
342 let mut parameters = Vec::new();
343 let mut starts: Vec<usize> = Vec::new();
344 let mut offsets: Vec<usize> = Vec::new();
345 parameters
346 .try_reserve_exact(reflection_count)
347 .map_err(|_| ProfileError::AllocationOverflow)?;
348 starts
349 .try_reserve_exact(reflection_count)
350 .map_err(|_| ProfileError::AllocationOverflow)?;
351 offsets
352 .try_reserve_exact(
353 reflection_count
354 .checked_add(1)
355 .ok_or(ProfileError::AllocationOverflow)?,
356 )
357 .map_err(|_| ProfileError::AllocationOverflow)?;
358 offsets.push(0);
359
360 for reflection in 0..reflection_count {
361 let profile = CwProfileParameters::from_validated_instrument(
362 reflections.two_theta_deg[reflection],
363 instrument,
364 )
365 .map_err(|reason| CwBatchError::InvalidReflection { reflection, reason })?;
366 let range = support.range(
367 reflections.two_theta_deg[reflection],
368 profile.tch.total_fwhm,
369 );
370 let lower = x.partition_point(|value| *value < range.left);
371 let upper = x.partition_point(|value| *value <= range.right);
372 let next_offset = offsets[reflection]
373 .checked_add(upper - lower)
374 .ok_or(ProfileError::AllocationOverflow)?;
375 parameters.push(profile);
376 starts.push(lower);
377 offsets.push(next_offset);
378 }
379
380 let active_sample_count = offsets.last().copied().unwrap_or(0);
381 let local_value_count = active_sample_count
382 .checked_mul(LOCAL_PARAMETER_COUNT)
383 .ok_or(ProfileError::AllocationOverflow)?;
384 let global_value_count = GLOBAL_PARAMETER_COUNT
385 .checked_mul(x.len())
386 .ok_or(ProfileError::AllocationOverflow)?;
387 let mut y = zeroed_f64_vec(x.len())?;
388 let mut local_values = zeroed_f64_vec(local_value_count)?;
389 let mut global_values = zeroed_f64_vec(global_value_count)?;
390
391 for reflection in 0..reflection_count {
392 let start = starts[reflection];
393 let active_begin = offsets[reflection];
394 let active_end = offsets[reflection + 1];
395 let profile = parameters[reflection];
396 let intensity = reflections.intensities[reflection];
397 for active_index in active_begin..active_end {
398 let sample = start + active_index - active_begin;
399 let point = profile
400 .tch
401 .evaluate(x[sample] - reflections.two_theta_deg[reflection]);
402 y[sample] += intensity * point.value;
403 let local_base = active_index * LOCAL_PARAMETER_COUNT;
404 local_values[local_base] = point.value;
405 local_values[local_base + 1] = intensity
406 * (-point.d_delta
407 + point.d_gaussian_fwhm * profile.d_gaussian_fwhm_d_two_theta
408 + point.d_lorentzian_fwhm * profile.d_lorentzian_fwhm_d_two_theta);
409 for parameter in 0..GLOBAL_PARAMETER_COUNT {
410 let derivative = point.d_gaussian_fwhm
411 * profile.d_gaussian_fwhm_d_instrument[parameter]
412 + point.d_lorentzian_fwhm * profile.d_lorentzian_fwhm_d_instrument[parameter];
413 global_values[parameter * x.len() + sample] += intensity * derivative;
414 }
415 }
416 }
417
418 Ok(Accumulation {
419 y,
420 derivatives: PatternDerivatives {
421 local: SupportJacobian {
422 starts,
423 offsets,
424 values: local_values,
425 parameter_count: LOCAL_PARAMETER_COUNT,
426 },
427 global: Some(DenseJacobian {
428 values: global_values,
429 parameter_count: GLOBAL_PARAMETER_COUNT,
430 sample_count: x.len(),
431 }),
432 },
433 sample_count: x.len(),
434 })
435}
436
437fn validate_two_theta(two_theta_deg: f64) -> Result<(), CwError> {
438 if !two_theta_deg.is_finite() || !(0.0..180.0).contains(&two_theta_deg) || two_theta_deg == 0.0
439 {
440 return Err(CwError::InvalidTwoTheta);
441 }
442 Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn instrument() -> ConstantWavelengthInstrument {
450 ConstantWavelengthInstrument {
451 wavelength_angstrom: 1.5406,
452 u_deg2: 2e-4,
453 v_deg2: -1e-4,
454 w_deg2: 1e-4,
455 x_deg: 1e-3,
456 y_deg: 2e-3,
457 }
458 }
459
460 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
461 assert!(
462 (actual - expected).abs() <= tolerance,
463 "actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.1e}"
464 );
465 }
466
467 fn assert_relative_close(actual: f64, expected: f64, relative_tolerance: f64) {
468 let scale = actual.abs().max(expected.abs()).max(f64::MIN_POSITIVE);
469 assert_close(actual, expected, relative_tolerance * scale);
470 }
471
472 #[test]
473 fn gsas_unit_converted_width_formula_is_exact() {
474 let position = 19.712_609_2;
475 let profile = CwProfileParameters::from_instrument(position, instrument()).expect("valid");
476 assert_close(profile.gaussian_variance_deg2 * 1e4, 0.886_630_51, 5e-9);
477 let theta = position * DEGREE_HALF_ANGLE_TO_RADIAN;
478 let expected_lorentzian = 1e-3 / theta.cos() + 2e-3 * theta.tan();
479 assert_close(profile.lorentzian_fwhm_deg, expected_lorentzian, 1e-18);
480 }
481
482 #[test]
483 fn derived_width_derivatives_match_centered_differences() {
484 let position = 63.2;
485 let baseline = CwProfileParameters::from_instrument(position, instrument()).expect("valid");
486 let instrument_step = 1e-8;
487 for parameter in 0..GLOBAL_PARAMETER_COUNT {
488 let mut plus = instrument();
489 let mut minus = instrument();
490 let plus_parameter = match parameter {
491 0 => &mut plus.u_deg2,
492 1 => &mut plus.v_deg2,
493 2 => &mut plus.w_deg2,
494 3 => &mut plus.x_deg,
495 _ => &mut plus.y_deg,
496 };
497 *plus_parameter += instrument_step;
498 let minus_parameter = match parameter {
499 0 => &mut minus.u_deg2,
500 1 => &mut minus.v_deg2,
501 2 => &mut minus.w_deg2,
502 3 => &mut minus.x_deg,
503 _ => &mut minus.y_deg,
504 };
505 *minus_parameter -= instrument_step;
506 let plus_profile = CwProfileParameters::from_instrument(position, plus).expect("plus");
507 let minus_profile =
508 CwProfileParameters::from_instrument(position, minus).expect("minus");
509 assert_relative_close(
510 baseline.d_gaussian_fwhm_d_instrument[parameter],
511 (plus_profile.gaussian_fwhm_deg - minus_profile.gaussian_fwhm_deg)
512 / (2.0 * instrument_step),
513 2e-8,
514 );
515 assert_close(
516 baseline.d_lorentzian_fwhm_d_instrument[parameter],
517 (plus_profile.lorentzian_fwhm_deg - minus_profile.lorentzian_fwhm_deg)
518 / (2.0 * instrument_step),
519 2e-10,
520 );
521 }
522 let position_step = 1e-5;
523 let plus = CwProfileParameters::from_instrument(position + position_step, instrument())
524 .expect("+");
525 let minus = CwProfileParameters::from_instrument(position - position_step, instrument())
526 .expect("-");
527 assert_close(
528 baseline.d_gaussian_fwhm_d_two_theta,
529 (plus.gaussian_fwhm_deg - minus.gaussian_fwhm_deg) / (2.0 * position_step),
530 2e-10,
531 );
532 assert_close(
533 baseline.d_lorentzian_fwhm_d_two_theta,
534 (plus.lorentzian_fwhm_deg - minus.lorentzian_fwhm_deg) / (2.0 * position_step),
535 2e-11,
536 );
537 }
538
539 #[test]
540 fn invalid_derived_widths_are_errors() {
541 assert_eq!(
542 CwProfileParameters::from_instrument(
543 30.0,
544 ConstantWavelengthInstrument {
545 w_deg2: -1.0,
546 ..instrument()
547 },
548 ),
549 Err(CwError::NonPositiveGaussianVariance)
550 );
551 assert_eq!(
552 CwProfileParameters::from_instrument(
553 30.0,
554 ConstantWavelengthInstrument {
555 x_deg: -1.0,
556 y_deg: 0.0,
557 ..instrument()
558 },
559 ),
560 Err(CwError::NegativeLorentzianFwhm)
561 );
562 }
563}