1use 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#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum CwComponentsBatchError {
26 InvalidInstrument {
28 reason: CwError,
30 },
31 InvalidComponents {
33 reason: WavelengthComponentsError,
35 },
36 ReferenceWavelengthMismatch,
38 InvalidComponentPosition {
40 reflection: usize,
42 component: usize,
44 },
45 InvalidComponentProfile {
47 reflection: usize,
49 component: usize,
51 reason: CwError,
53 },
54 InvalidComponentGeometry {
56 reflection: usize,
58 component: usize,
60 reason: FcjError,
62 },
63 Accumulation {
65 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
413pub 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
443pub 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}