Skip to main content

phasesmith_core/
cw_components.rs

1//! Fused CW wavelength-component accumulation with optional FCJ asymmetry.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::cw::{
7    ConstantWavelengthInstrument, CwError, CwProfileParameters, CwReflectionBatchView,
8};
9use crate::fcj::{FcjError, FcjGeometry, FcjProfile};
10use crate::profile::{
11    Accumulation, DenseJacobian, GridView, PatternDerivatives, ProfileError, SupportJacobian,
12    SupportPolicy, zeroed_f64_vec,
13};
14use crate::radiation::{WavelengthComponentsError, WavelengthComponentsView};
15use crate::tch::TchWidths;
16
17const DEGREE_HALF_ANGLE_TO_RADIAN: f64 = std::f64::consts::PI / 360.0;
18const TWO_THETA_RADIAN_TO_DEGREE: f64 = 360.0 / std::f64::consts::PI;
19const LOCAL_PARAMETER_COUNT: usize = 2;
20const CW_PARAMETER_COUNT: usize = 5;
21const FCJ_PARAMETER_COUNT: usize = 2;
22
23/// Errors while accumulating a CW wavelength-component batch.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum CwComponentsBatchError {
26    /// The shared constant-wavelength instrument is invalid.
27    InvalidInstrument {
28        /// Instrument validation failure.
29        reason: CwError,
30    },
31    /// The wavelength-component arrays are invalid.
32    InvalidComponents {
33        /// Component validation failure.
34        reason: WavelengthComponentsError,
35    },
36    /// Component zero does not match the instrument reference wavelength.
37    ReferenceWavelengthMismatch,
38    /// Bragg's law cannot represent a wavelength at one reflection d-spacing.
39    InvalidComponentPosition {
40        /// Index of the invalid reflection.
41        reflection: usize,
42        /// Index of the invalid wavelength component.
43        component: usize,
44    },
45    /// A component angle or its derived widths are invalid.
46    InvalidComponentProfile {
47        /// Index of the invalid reflection.
48        reflection: usize,
49        /// Index of the invalid wavelength component.
50        component: usize,
51        /// Component-specific CW failure.
52        reason: CwError,
53    },
54    /// A component cannot be represented by the requested FCJ geometry.
55    InvalidComponentGeometry {
56        /// Index of the invalid reflection.
57        reflection: usize,
58        /// Index of the invalid wavelength component.
59        component: usize,
60        /// Component-specific FCJ failure.
61        reason: FcjError,
62    },
63    /// Grid, support, or allocation validation failed.
64    Accumulation {
65        /// Underlying generic profile failure.
66        reason: ProfileError,
67    },
68}
69
70impl Display for CwComponentsBatchError {
71    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::InvalidInstrument { reason } => {
74                write!(
75                    formatter,
76                    "invalid constant-wavelength instrument: {reason}"
77                )
78            }
79            Self::InvalidComponents { reason } => {
80                write!(formatter, "invalid wavelength components: {reason}")
81            }
82            Self::ReferenceWavelengthMismatch => write!(
83                formatter,
84                "component zero wavelength must match the instrument reference wavelength"
85            ),
86            Self::InvalidComponentPosition {
87                reflection,
88                component,
89            } => write!(
90                formatter,
91                "wavelength component {component} is outside the Bragg domain for reflection {reflection}"
92            ),
93            Self::InvalidComponentProfile {
94                reflection,
95                component,
96                reason,
97            } => write!(
98                formatter,
99                "CW component {component} for reflection {reflection} is invalid: {reason}"
100            ),
101            Self::InvalidComponentGeometry {
102                reflection,
103                component,
104                reason,
105            } => write!(
106                formatter,
107                "FCJ component {component} for reflection {reflection} is invalid: {reason}"
108            ),
109            Self::Accumulation { reason } => Display::fmt(reason, formatter),
110        }
111    }
112}
113
114impl Error for CwComponentsBatchError {}
115
116impl From<ProfileError> for CwComponentsBatchError {
117    fn from(reason: ProfileError) -> Self {
118        Self::Accumulation { reason }
119    }
120}
121
122#[derive(Clone, Debug)]
123struct PreparedComponent {
124    cw: CwProfileParameters,
125    fcj: FcjProfile,
126    support_radius_deg: f64,
127    d_position_d_base_position: f64,
128    d_position_d_wavelength_ratio: f64,
129}
130
131struct PreparedBatch {
132    components: Vec<PreparedComponent>,
133    starts: Vec<usize>,
134    offsets: Vec<usize>,
135    normalized_weights: Vec<f64>,
136}
137
138fn normalized_component_weights(
139    components: WavelengthComponentsView<'_>,
140) -> Result<Vec<f64>, ProfileError> {
141    let maximum = (0..components.len())
142        .map(|component| components.relative_intensity(component))
143        .fold(0.0, f64::max);
144    let scaled_sum: f64 = (0..components.len())
145        .map(|component| components.relative_intensity(component) / maximum)
146        .sum();
147    let mut weights = Vec::new();
148    weights
149        .try_reserve_exact(components.len())
150        .map_err(|_| ProfileError::AllocationOverflow)?;
151    for component in 0..components.len() {
152        weights.push(components.relative_intensity(component) / maximum / scaled_sum);
153    }
154    Ok(weights)
155}
156
157fn validate_reference_wavelength(
158    instrument: ConstantWavelengthInstrument,
159    components: WavelengthComponentsView<'_>,
160) -> Result<(), CwComponentsBatchError> {
161    let reference = components.wavelength(0);
162    let scale = reference.abs().max(instrument.wavelength_angstrom.abs());
163    if (reference - instrument.wavelength_angstrom).abs() > 16.0 * f64::EPSILON * scale {
164        return Err(CwComponentsBatchError::ReferenceWavelengthMismatch);
165    }
166    Ok(())
167}
168
169#[allow(clippy::too_many_arguments)]
170fn prepare_component(
171    reflection: usize,
172    component: usize,
173    base_position_deg: f64,
174    instrument: ConstantWavelengthInstrument,
175    components: WavelengthComponentsView<'_>,
176    geometry: FcjGeometry,
177    support: SupportPolicy,
178) -> Result<PreparedComponent, CwComponentsBatchError> {
179    let base_theta = base_position_deg * DEGREE_HALF_ANGLE_TO_RADIAN;
180    let wavelength_ratio = components.wavelength(component) / instrument.wavelength_angstrom;
181    let component_sine = wavelength_ratio * base_theta.sin();
182    if !component_sine.is_finite() || !(0.0..1.0).contains(&component_sine) {
183        return Err(CwComponentsBatchError::InvalidComponentPosition {
184            reflection,
185            component,
186        });
187    }
188    let (position_deg, d_position_d_base_position) = if component == 0 {
189        (base_position_deg, 1.0)
190    } else {
191        let component_theta = component_sine.asin();
192        (
193            component_theta * TWO_THETA_RADIAN_TO_DEGREE,
194            wavelength_ratio * base_theta.cos() / component_theta.cos(),
195        )
196    };
197    let component_theta = position_deg * DEGREE_HALF_ANGLE_TO_RADIAN;
198    let d_position_d_wavelength_ratio =
199        TWO_THETA_RADIAN_TO_DEGREE * base_theta.sin() / component_theta.cos();
200    let cw = CwProfileParameters::from_validated_instrument(position_deg, instrument).map_err(
201        |reason| CwComponentsBatchError::InvalidComponentProfile {
202            reflection,
203            component,
204            reason,
205        },
206    )?;
207    let fcj = FcjProfile::new(
208        position_deg,
209        TchWidths {
210            gaussian_fwhm: cw.gaussian_fwhm_deg,
211            lorentzian_fwhm: cw.lorentzian_fwhm_deg,
212        },
213        geometry,
214    )
215    .map_err(|reason| CwComponentsBatchError::InvalidComponentGeometry {
216        reflection,
217        component,
218        reason,
219    })?;
220    let support_radius_deg = support.radius(cw.tch.total_fwhm);
221    Ok(PreparedComponent {
222        cw,
223        fcj,
224        support_radius_deg,
225        d_position_d_base_position,
226        d_position_d_wavelength_ratio,
227    })
228}
229
230fn prepare_batch(
231    x: &[f64],
232    reflections: CwReflectionBatchView<'_>,
233    instrument: ConstantWavelengthInstrument,
234    components: WavelengthComponentsView<'_>,
235    geometry: FcjGeometry,
236    support: SupportPolicy,
237) -> Result<PreparedBatch, CwComponentsBatchError> {
238    let reflection_count = reflections.len();
239    let component_count = components.len();
240    let prepared_count = reflection_count
241        .checked_mul(component_count)
242        .ok_or(ProfileError::AllocationOverflow)?;
243    let mut prepared = Vec::new();
244    prepared
245        .try_reserve_exact(prepared_count)
246        .map_err(|_| ProfileError::AllocationOverflow)?;
247    let mut starts = Vec::new();
248    starts
249        .try_reserve_exact(reflection_count)
250        .map_err(|_| ProfileError::AllocationOverflow)?;
251    let mut offsets: Vec<usize> = Vec::new();
252    offsets
253        .try_reserve_exact(
254            reflection_count
255                .checked_add(1)
256                .ok_or(ProfileError::AllocationOverflow)?,
257        )
258        .map_err(|_| ProfileError::AllocationOverflow)?;
259    offsets.push(0);
260
261    for reflection in 0..reflection_count {
262        let mut support_left = f64::INFINITY;
263        let mut support_right = f64::NEG_INFINITY;
264        for component in 0..component_count {
265            let profile = prepare_component(
266                reflection,
267                component,
268                reflections.position(reflection),
269                instrument,
270                components,
271                geometry,
272                support,
273            )?;
274            let range = profile.fcj.support_range(profile.support_radius_deg);
275            support_left = support_left.min(range.left);
276            support_right = support_right.max(range.right);
277            prepared.push(profile);
278        }
279        let lower = x.partition_point(|value| *value < support_left);
280        let upper = x.partition_point(|value| *value <= support_right);
281        let next_offset = offsets[reflection]
282            .checked_add(upper - lower)
283            .ok_or(ProfileError::AllocationOverflow)?;
284        starts.push(lower);
285        offsets.push(next_offset);
286    }
287    Ok(PreparedBatch {
288        components: prepared,
289        starts,
290        offsets,
291        normalized_weights: normalized_component_weights(components)?,
292    })
293}
294
295#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
296fn accumulate_components(
297    grid: GridView<'_>,
298    reflections: CwReflectionBatchView<'_>,
299    instrument: ConstantWavelengthInstrument,
300    components: WavelengthComponentsView<'_>,
301    geometry: FcjGeometry,
302    include_fcj_derivatives: bool,
303    support: SupportPolicy,
304) -> Result<Accumulation, CwComponentsBatchError> {
305    support.validate()?;
306    instrument
307        .validate()
308        .map_err(|reason| CwComponentsBatchError::InvalidInstrument { reason })?;
309    validate_reference_wavelength(instrument, components)?;
310    let x = grid.as_slice();
311    let reflection_count = reflections.len();
312    let component_count = components.len();
313    let secondary_count = component_count - 1;
314    let fcj_parameter_count = usize::from(include_fcj_derivatives) * FCJ_PARAMETER_COUNT;
315    let secondary_parameter_count = secondary_count
316        .checked_mul(2)
317        .ok_or(ProfileError::AllocationOverflow)?;
318    let global_parameter_count = CW_PARAMETER_COUNT
319        .checked_add(fcj_parameter_count)
320        .and_then(|count| count.checked_add(secondary_parameter_count))
321        .ok_or(ProfileError::AllocationOverflow)?;
322    let wavelength_parameter_start = CW_PARAMETER_COUNT + fcj_parameter_count;
323    let intensity_parameter_start = wavelength_parameter_start + secondary_count;
324    let prepared = prepare_batch(x, reflections, instrument, components, geometry, support)?;
325
326    let active_sample_count = prepared.offsets.last().copied().unwrap_or(0);
327    let local_value_count = active_sample_count
328        .checked_mul(LOCAL_PARAMETER_COUNT)
329        .ok_or(ProfileError::AllocationOverflow)?;
330    let global_value_count = global_parameter_count
331        .checked_mul(x.len())
332        .ok_or(ProfileError::AllocationOverflow)?;
333    let mut y = zeroed_f64_vec(x.len())?;
334    let mut local_values = zeroed_f64_vec(local_value_count)?;
335    let mut global_values = zeroed_f64_vec(global_value_count)?;
336    let mut component_values = zeroed_f64_vec(component_count)?;
337
338    for reflection in 0..reflection_count {
339        let start = prepared.starts[reflection];
340        let active_begin = prepared.offsets[reflection];
341        let active_end = prepared.offsets[reflection + 1];
342        let intensity = reflections.intensity(reflection);
343        for active_index in active_begin..active_end {
344            let sample = start + active_index - active_begin;
345            let mut mixture_value = 0.0;
346            let mut mixture_d_base_position = 0.0;
347            for (component, component_value) in component_values.iter_mut().enumerate() {
348                let profile = &prepared.components[reflection * component_count + component];
349                let weight = prepared.normalized_weights[component];
350                let point = profile
351                    .fcj
352                    .evaluate_supported(x[sample], profile.support_radius_deg);
353                *component_value = point.value;
354                mixture_value += weight * point.value;
355                let d_profile_d_component_position = point.d_position
356                    + point.d_gaussian_fwhm * profile.cw.d_gaussian_fwhm_d_two_theta
357                    + point.d_lorentzian_fwhm * profile.cw.d_lorentzian_fwhm_d_two_theta;
358                mixture_d_base_position +=
359                    weight * d_profile_d_component_position * profile.d_position_d_base_position;
360                for parameter in 0..CW_PARAMETER_COUNT {
361                    let derivative = point.d_gaussian_fwhm
362                        * profile.cw.d_gaussian_fwhm_d_instrument[parameter]
363                        + point.d_lorentzian_fwhm
364                            * profile.cw.d_lorentzian_fwhm_d_instrument[parameter];
365                    global_values[parameter * x.len() + sample] += intensity * weight * derivative;
366                }
367                if include_fcj_derivatives {
368                    global_values[CW_PARAMETER_COUNT * x.len() + sample] +=
369                        intensity * weight * point.d_sample_over_radius;
370                    global_values[(CW_PARAMETER_COUNT + 1) * x.len() + sample] +=
371                        intensity * weight * point.d_detector_over_radius;
372                }
373                if component > 0 {
374                    let parameter = wavelength_parameter_start + component - 1;
375                    global_values[parameter * x.len() + sample] += intensity
376                        * weight
377                        * d_profile_d_component_position
378                        * profile.d_position_d_wavelength_ratio;
379                }
380            }
381            y[sample] += intensity * mixture_value;
382            let local_base = active_index * LOCAL_PARAMETER_COUNT;
383            local_values[local_base] = mixture_value;
384            local_values[local_base + 1] = intensity * mixture_d_base_position;
385            for secondary in 0..secondary_count {
386                let parameter = intensity_parameter_start + secondary;
387                global_values[parameter * x.len() + sample] += intensity
388                    * prepared.normalized_weights[0]
389                    * (component_values[secondary + 1] - mixture_value);
390            }
391        }
392    }
393
394    Ok(Accumulation {
395        y,
396        derivatives: PatternDerivatives {
397            local: SupportJacobian {
398                starts: prepared.starts,
399                offsets: prepared.offsets,
400                values: local_values,
401                parameter_count: LOCAL_PARAMETER_COUNT,
402            },
403            global: Some(DenseJacobian {
404                values: global_values,
405                parameter_count: global_parameter_count,
406                sample_count: x.len(),
407            }),
408        },
409        sample_count: x.len(),
410    })
411}
412
413/// Accumulate a symmetric CW wavelength-component batch.
414///
415/// Shared derivative order is U, V, W, X, Y, then one wavelength-ratio row
416/// and one intensity-ratio row for every secondary component.
417///
418/// # Errors
419///
420/// Returns [`CwComponentsBatchError`] for invalid inputs, component Bragg
421/// domains, support, or allocation.
422pub fn accumulate_cw_components_batch(
423    grid: GridView<'_>,
424    reflections: CwReflectionBatchView<'_>,
425    instrument: ConstantWavelengthInstrument,
426    components: WavelengthComponentsView<'_>,
427    support: SupportPolicy,
428) -> Result<Accumulation, CwComponentsBatchError> {
429    accumulate_components(
430        grid,
431        reflections,
432        instrument,
433        components,
434        FcjGeometry {
435            sample_over_radius: 0.0,
436            detector_over_radius: 0.0,
437        },
438        false,
439        support,
440    )
441}
442
443/// Accumulate an FCJ-asymmetric CW wavelength-component batch.
444///
445/// Shared derivative order is U, V, W, X, Y, the two FCJ ratios, then one
446/// wavelength-ratio row and one intensity-ratio row for every secondary
447/// component.
448///
449/// # Errors
450///
451/// Returns [`CwComponentsBatchError`] for invalid inputs, component Bragg/FCJ
452/// domains, support, or allocation.
453pub fn accumulate_cw_fcj_components_batch(
454    grid: GridView<'_>,
455    reflections: CwReflectionBatchView<'_>,
456    instrument: ConstantWavelengthInstrument,
457    components: WavelengthComponentsView<'_>,
458    geometry: FcjGeometry,
459    support: SupportPolicy,
460) -> Result<Accumulation, CwComponentsBatchError> {
461    accumulate_components(
462        grid,
463        reflections,
464        instrument,
465        components,
466        geometry,
467        true,
468        support,
469    )
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn instrument() -> ConstantWavelengthInstrument {
477        ConstantWavelengthInstrument {
478            wavelength_angstrom: 1.540_56,
479            u_deg2: 2.0e-4,
480            v_deg2: -1.0e-4,
481            w_deg2: 1.2e-4,
482            x_deg: 1.5e-3,
483            y_deg: 3.0e-3,
484        }
485    }
486
487    #[test]
488    fn single_component_matches_cw_values() {
489        let x: Vec<f64> = (0..=2_000)
490            .map(|index| 39.0 + f64::from(index) * 0.001)
491            .collect();
492        let positions = [39.8, 40.2];
493        let intensities = [12.0, 7.0];
494        let wavelengths = [1.540_56];
495        let weights = [1.0];
496        let grid = GridView::new(&x).expect("grid");
497        let reflections =
498            CwReflectionBatchView::new(&positions, &intensities).expect("reflections");
499        let components = WavelengthComponentsView::new(&wavelengths, &weights).expect("components");
500        let support = SupportPolicy::FwhmMultiple(20.0);
501        let expected =
502            crate::cw::accumulate_cw_batch(grid, reflections, instrument(), support).expect("CW");
503        let actual =
504            accumulate_cw_components_batch(grid, reflections, instrument(), components, support)
505                .expect("components");
506        assert_eq!(actual, expected);
507    }
508
509    #[test]
510    #[allow(clippy::too_many_lines)]
511    fn all_component_and_fcj_derivatives_match_centered_differences() {
512        let x: Vec<f64> = (0..=1_600)
513            .map(|index| 49.7 + f64::from(index) * 0.000_5)
514            .collect();
515        let positions = [50.0];
516        let intensities = [8.0];
517        let support = SupportPolicy::FwhmMultiple(100.0);
518        let base_instrument = instrument();
519        let base_geometry = FcjGeometry {
520            sample_over_radius: 0.012,
521            detector_over_radius: 0.008,
522        };
523        let wavelength_ratio = 1.544_39 / base_instrument.wavelength_angstrom;
524        let intensity_ratio = 0.5;
525
526        let calculate = |selected_instrument: ConstantWavelengthInstrument,
527                         geometry: FcjGeometry,
528                         ratio: f64,
529                         relative_intensity: f64| {
530            let wavelengths = [
531                selected_instrument.wavelength_angstrom,
532                selected_instrument.wavelength_angstrom * ratio,
533            ];
534            let weights = [1.0, relative_intensity];
535            accumulate_cw_fcj_components_batch(
536                GridView::new(&x).expect("grid"),
537                CwReflectionBatchView::new(&positions, &intensities).expect("reflections"),
538                selected_instrument,
539                WavelengthComponentsView::new(&wavelengths, &weights).expect("components"),
540                geometry,
541                support,
542            )
543            .expect("component accumulation")
544        };
545
546        let baseline = calculate(
547            base_instrument,
548            base_geometry,
549            wavelength_ratio,
550            intensity_ratio,
551        );
552        let global = baseline.derivatives.global.as_ref().expect("global");
553        for row in 0..9 {
554            let step = if row <= 2 { 1.0e-8 } else { 1.0e-7 };
555            let mut plus_instrument = base_instrument;
556            let mut minus_instrument = base_instrument;
557            let mut plus_geometry = base_geometry;
558            let mut minus_geometry = base_geometry;
559            let mut plus_wavelength_ratio = wavelength_ratio;
560            let mut minus_wavelength_ratio = wavelength_ratio;
561            let mut plus_intensity_ratio = intensity_ratio;
562            let mut minus_intensity_ratio = intensity_ratio;
563            match row {
564                0 => {
565                    plus_instrument.u_deg2 += step;
566                    minus_instrument.u_deg2 -= step;
567                }
568                1 => {
569                    plus_instrument.v_deg2 += step;
570                    minus_instrument.v_deg2 -= step;
571                }
572                2 => {
573                    plus_instrument.w_deg2 += step;
574                    minus_instrument.w_deg2 -= step;
575                }
576                3 => {
577                    plus_instrument.x_deg += step;
578                    minus_instrument.x_deg -= step;
579                }
580                4 => {
581                    plus_instrument.y_deg += step;
582                    minus_instrument.y_deg -= step;
583                }
584                5 => {
585                    plus_geometry.sample_over_radius += step;
586                    minus_geometry.sample_over_radius -= step;
587                }
588                6 => {
589                    plus_geometry.detector_over_radius += step;
590                    minus_geometry.detector_over_radius -= step;
591                }
592                7 => {
593                    plus_wavelength_ratio += step;
594                    minus_wavelength_ratio -= step;
595                }
596                8 => {
597                    plus_intensity_ratio += step;
598                    minus_intensity_ratio -= step;
599                }
600                _ => unreachable!(),
601            }
602            let plus = calculate(
603                plus_instrument,
604                plus_geometry,
605                plus_wavelength_ratio,
606                plus_intensity_ratio,
607            );
608            let minus = calculate(
609                minus_instrument,
610                minus_geometry,
611                minus_wavelength_ratio,
612                minus_intensity_ratio,
613            );
614            for (sample, (&plus_value, &minus_value)) in plus.y.iter().zip(&minus.y).enumerate() {
615                let finite_difference = (plus_value - minus_value) / (2.0 * step);
616                let analytical = global.values[row * x.len() + sample];
617                assert!(
618                    (analytical - finite_difference).abs()
619                        < 1.3e-5 * finite_difference.abs().max(1.0),
620                    "row {row}, sample {sample}: analytical={analytical}, finite={finite_difference}"
621                );
622            }
623        }
624    }
625}