1use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::profile::SupportRange;
7use crate::tch::{TchError, TchShape, TchWidths};
8
9const DEGREE_TO_RADIAN: f64 = std::f64::consts::PI / 180.0;
10const RADIAN_TO_DEGREE: f64 = 180.0 / std::f64::consts::PI;
11pub(crate) const QUADRATURE_ORDER: usize = 48;
12const SMALL_SPAN_QUADRATURE_ORDER: usize = 8;
13const SMALL_SPAN_RATIO_LIMIT: f64 = 0.2;
16
17const SMALL_SPAN_QUADRATURE_NODES: [f64; SMALL_SPAN_QUADRATURE_ORDER] = [
18 1.985_507_175_123_191_2e-2,
19 1.016_667_612_931_866_4e-1,
20 2.372_337_950_418_355e-1,
21 4.082_826_787_521_750_5e-1,
22 5.917_173_212_478_25e-1,
23 7.627_662_049_581_645e-1,
24 8.983_332_387_068_134e-1,
25 9.801_449_282_487_681e-1,
26];
27
28const SMALL_SPAN_QUADRATURE_WEIGHTS: [f64; SMALL_SPAN_QUADRATURE_ORDER] = [
29 5.061_426_814_518_826e-2,
30 1.111_905_172_266_872_3e-1,
31 1.568_533_229_389_435_8e-1,
32 1.813_418_916_891_809e-1,
33 1.813_418_916_891_809e-1,
34 1.568_533_229_389_435_8e-1,
35 1.111_905_172_266_872_3e-1,
36 5.061_426_814_518_826e-2,
37];
38
39pub(crate) const QUADRATURE_NODES: [f64; QUADRATURE_ORDER] = [
41 6.144_963_737_869_658e-4,
42 3.234_913_866_824_618e-3,
43 7.937_708_138_586_574e-3,
44 1.470_420_372_687_636_4e-2,
45 2.350_614_841_978_46e-2,
46 3.430_665_464_672_283_4e-2,
47 4.706_043_164_221_518_4e-2,
48 6.171_398_986_287_607e-2,
49 7.820_586_918_780_326e-2,
50 9.646_689_798_527_869e-2,
51 1.164_204_837_421_298_2e-1,
52 1.379_829_345_380_926_8e-1,
53 1.610_638_101_836_680_5e-1,
54 1.855_663_016_117_432e-1,
55 2.113_876_369_580_136_6e-1,
56 2.384_195_126_388_835e-1,
57 2.665_485_476_245_208e-1,
58 2.956_567_590_046_416e-1,
59 3.256_220_568_539_196_5e-1,
60 3.563_187_563_222_722e-1,
61 3.876_181_048_026_554_6e-1,
62 4.193_888_219_655_541_6e-1,
63 4.514_976_503_952_687e-1,
64 4.838_099_145_185_653e-1,
65 5.161_900_854_814_346e-1,
66 5.485_023_496_047_313e-1,
67 5.806_111_780_344_458e-1,
68 6.123_818_951_973_445e-1,
69 6.436_812_436_777_277e-1,
70 6.743_779_431_460_803e-1,
71 7.043_432_409_953_584e-1,
72 7.334_514_523_754_792e-1,
73 7.615_804_873_611_165e-1,
74 7.886_123_630_419_863e-1,
75 8.144_336_983_882_567e-1,
76 8.389_361_898_163_319e-1,
77 8.620_170_654_619_073e-1,
78 8.835_795_162_578_701e-1,
79 9.035_331_020_147_213e-1,
80 9.217_941_308_121_967e-1,
81 9.382_860_101_371_24e-1,
82 9.529_395_683_577_848e-1,
83 9.656_933_453_532_772e-1,
84 9.764_938_515_802_154e-1,
85 9.852_957_962_731_237e-1,
86 9.920_622_918_614_135e-1,
87 9.967_650_861_331_754e-1,
88 9.993_855_036_262_13e-1,
89];
90
91pub(crate) const QUADRATURE_WEIGHTS: [f64; QUADRATURE_ORDER] = [
92 1.576_673_026_154_921e-3,
93 3.663_776_950_637_925_2e-3,
94 5.738_617_289_617_35e-3,
95 7.789_657_861_471_74e-3,
96 9.808_080_228_678_052e-3,
97 1.178_538_041_966_200_5e-2,
98 1.371_325_485_417_852_6e-2,
99 1.558_361_391_639_905_8e-2,
100 1.738_861_128_238_521e-2,
101 1.912_067_553_291_523_6e-2,
102 2.077_254_147_173_226_6e-2,
103 2.233_728_042_834_712_3e-2,
104 2.380_832_924_624_513_5e-2,
105 2.517_951_777_692_711e-2,
106 2.644_509_474_259_671_2e-2,
107 2.759_975_184_999_202e-2,
108 2.863_864_605_020_144e-2,
109 2.955_741_984_919_768e-2,
110 3.035_221_958_294_678e-2,
111 3.101_971_157_994_621e-2,
112 3.155_709_614_312_688e-2,
113 3.196_211_929_232_394e-2,
114 3.223_308_221_797_490_5e-2,
115 3.236_884_840_634_181e-2,
116 3.236_884_840_634_181e-2,
117 3.223_308_221_797_490_5e-2,
118 3.196_211_929_232_394e-2,
119 3.155_709_614_312_688e-2,
120 3.101_971_157_994_621e-2,
121 3.035_221_958_294_678e-2,
122 2.955_741_984_919_768e-2,
123 2.863_864_605_020_144e-2,
124 2.759_975_184_999_202e-2,
125 2.644_509_474_259_671_2e-2,
126 2.517_951_777_692_711e-2,
127 2.380_832_924_624_513_5e-2,
128 2.233_728_042_834_712_3e-2,
129 2.077_254_147_173_226_6e-2,
130 1.912_067_553_291_523_6e-2,
131 1.738_861_128_238_521e-2,
132 1.558_361_391_639_905_8e-2,
133 1.371_325_485_417_852_6e-2,
134 1.178_538_041_966_200_5e-2,
135 9.808_080_228_678_052e-3,
136 7.789_657_861_471_74e-3,
137 5.738_617_289_617_35e-3,
138 3.663_776_950_637_925_2e-3,
139 1.576_673_026_154_921e-3,
140];
141
142#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct FcjGeometry {
145 pub sample_over_radius: f64,
147 pub detector_over_radius: f64,
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq)]
153pub struct FcjProfilePoint {
154 pub value: f64,
156 pub d_position: f64,
158 pub d_gaussian_fwhm: f64,
160 pub d_lorentzian_fwhm: f64,
162 pub d_sample_over_radius: f64,
164 pub d_detector_over_radius: f64,
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum FcjError {
171 InvalidPosition,
173 InvalidGeometry,
175 GeometryOutsideAngularDomain,
177 InvalidWidths {
179 reason: TchError,
181 },
182 InvalidNormalization,
184}
185
186impl Display for FcjError {
187 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::InvalidPosition => {
190 write!(
191 formatter,
192 "FCJ position must be finite and within (0, 180) degrees"
193 )
194 }
195 Self::InvalidGeometry => {
196 write!(
197 formatter,
198 "FCJ axial ratios must be non-negative and finite"
199 )
200 }
201 Self::GeometryOutsideAngularDomain => {
202 write!(
203 formatter,
204 "FCJ axial geometry extends outside the angular domain"
205 )
206 }
207 Self::InvalidWidths { reason } => write!(formatter, "invalid TCH widths: {reason}"),
208 Self::InvalidNormalization => {
209 write!(formatter, "FCJ normalization is not positive and finite")
210 }
211 }
212 }
213}
214
215impl Error for FcjError {}
216
217#[derive(Clone, Copy, Debug, Default)]
218struct PreparedNode {
219 apparent_position_deg: f64,
220 weighted_geometry: f64,
221 d_weighted_geometry_d_position: f64,
222 d_weighted_geometry_d_major: f64,
223 d_weighted_geometry_d_minor: f64,
224 d_apparent_d_position: f64,
225 d_apparent_d_major: f64,
226 d_apparent_d_minor: f64,
227}
228
229#[derive(Clone, Debug)]
231pub struct FcjProfile {
232 shape: TchShape,
233 geometry: FcjGeometry,
234 position_deg: f64,
235 nodes: Box<[PreparedNode]>,
236 normalization: f64,
237 d_normalization_d_position: f64,
238 d_normalization_d_major: f64,
239 d_normalization_d_minor: f64,
240 apparent_limit_deg: f64,
241}
242
243impl FcjProfile {
244 pub fn new(
250 position_deg: f64,
251 widths: TchWidths,
252 geometry: FcjGeometry,
253 ) -> Result<Self, FcjError> {
254 validate_position(position_deg)?;
255 validate_geometry(geometry)?;
256 let shape = TchShape::from_component_fwhm(widths)
257 .map_err(|reason| FcjError::InvalidWidths { reason })?;
258 let maximum_height = geometry.sample_over_radius + geometry.detector_over_radius;
259 let position_rad = position_deg * DEGREE_TO_RADIAN;
260 let limit_argument = position_rad.cos() * (1.0 + maximum_height * maximum_height).sqrt();
261 if !(-1.0..=1.0).contains(&limit_argument) {
262 return Err(FcjError::GeometryOutsideAngularDomain);
263 }
264 let apparent_limit_deg = if maximum_height == 0.0 {
265 position_deg
266 } else {
267 limit_argument.acos() * RADIAN_TO_DEGREE
268 };
269 if maximum_height == 0.0 {
270 let nodes = Box::new([PreparedNode {
271 apparent_position_deg: position_deg,
272 weighted_geometry: 1.0,
273 d_apparent_d_position: 1.0,
274 ..PreparedNode::default()
275 }]);
276 return Ok(Self {
277 shape,
278 geometry,
279 position_deg,
280 nodes,
281 normalization: 1.0,
282 d_normalization_d_position: 0.0,
283 d_normalization_d_major: 0.0,
284 d_normalization_d_minor: 0.0,
285 apparent_limit_deg,
286 });
287 }
288
289 let major = geometry
290 .sample_over_radius
291 .max(geometry.detector_over_radius);
292 let minor = geometry
293 .sample_over_radius
294 .min(geometry.detector_over_radius);
295 let difference = major - minor;
296 let (quadrature_nodes, quadrature_weights) =
297 quadrature_rule((apparent_limit_deg - position_deg).abs(), shape.total_fwhm);
298 let piece_count = if difference == 0.0 { 1 } else { 2 };
299 let mut nodes = Vec::with_capacity(piece_count * quadrature_nodes.len());
300 let mut normalization = 0.0;
301 let mut d_normalization_d_position = 0.0;
302 let mut d_normalization_d_major = 0.0;
303 let mut d_normalization_d_minor = 0.0;
304 for (&t, &weight) in quadrature_nodes.iter().zip(quadrature_weights) {
305 if difference != 0.0 {
309 let flat = prepare_node(
310 position_rad,
311 difference * t,
312 difference * weight,
313 weight,
314 -weight,
315 t,
316 -t,
317 );
318 normalization += flat.weighted_geometry;
319 d_normalization_d_position += flat.d_weighted_geometry_d_position;
320 d_normalization_d_major += flat.d_weighted_geometry_d_major;
321 d_normalization_d_minor += flat.d_weighted_geometry_d_minor;
322 nodes.push(flat);
323 }
324 let slope_weight = 2.0 * minor * weight * (1.0 - t);
325 let slope = prepare_node(
326 position_rad,
327 difference + 2.0 * minor * t,
328 slope_weight,
329 0.0,
330 2.0 * weight * (1.0 - t),
331 1.0,
332 -1.0 + 2.0 * t,
333 );
334 normalization += slope.weighted_geometry;
335 d_normalization_d_position += slope.d_weighted_geometry_d_position;
336 d_normalization_d_major += slope.d_weighted_geometry_d_major;
337 d_normalization_d_minor += slope.d_weighted_geometry_d_minor;
338 nodes.push(slope);
339 }
340 if !normalization.is_finite() || normalization <= 0.0 {
341 return Err(FcjError::InvalidNormalization);
342 }
343 Ok(Self {
344 shape,
345 geometry,
346 position_deg,
347 nodes: nodes.into_boxed_slice(),
348 normalization,
349 d_normalization_d_position,
350 d_normalization_d_major,
351 d_normalization_d_minor,
352 apparent_limit_deg,
353 })
354 }
355
356 #[must_use]
358 pub fn evaluate(&self, x_deg: f64) -> FcjProfilePoint {
359 self.evaluate_with_radius(x_deg, f64::INFINITY)
360 }
361
362 #[must_use]
363 pub(crate) fn evaluate_supported(
364 &self,
365 x_deg: f64,
366 support_radius_deg: f64,
367 ) -> FcjProfilePoint {
368 self.evaluate_with_radius(x_deg, support_radius_deg)
369 }
370
371 #[must_use]
372 pub(crate) fn support_range(&self, support_radius_deg: f64) -> SupportRange {
373 SupportRange {
374 left: self.apparent_limit_deg.min(self.position_deg) - support_radius_deg,
375 right: self.apparent_limit_deg.max(self.position_deg) + support_radius_deg,
376 }
377 }
378
379 fn evaluate_with_radius(&self, x_deg: f64, support_radius_deg: f64) -> FcjProfilePoint {
380 let mut numerator = 0.0;
381 let mut numerator_position = 0.0;
382 let mut numerator_gaussian = 0.0;
383 let mut numerator_lorentzian = 0.0;
384 let mut numerator_major = 0.0;
385 let mut numerator_minor = 0.0;
386 for node in &self.nodes {
387 let delta = x_deg - node.apparent_position_deg;
388 if delta.abs() > support_radius_deg {
389 continue;
390 }
391 let point = self.shape.evaluate(delta);
392 numerator += node.weighted_geometry * point.value;
393 numerator_position += node.d_weighted_geometry_d_position * point.value
394 - node.weighted_geometry * point.d_delta * node.d_apparent_d_position;
395 numerator_gaussian += node.weighted_geometry * point.d_gaussian_fwhm;
396 numerator_lorentzian += node.weighted_geometry * point.d_lorentzian_fwhm;
397 numerator_major += node.d_weighted_geometry_d_major * point.value
398 - node.weighted_geometry * point.d_delta * node.d_apparent_d_major;
399 numerator_minor += node.d_weighted_geometry_d_minor * point.value
400 - node.weighted_geometry * point.d_delta * node.d_apparent_d_minor;
401 }
402 let value = numerator / self.normalization;
403 let d_position =
404 (numerator_position - value * self.d_normalization_d_position) / self.normalization;
405 let d_gaussian_fwhm = numerator_gaussian / self.normalization;
406 let d_lorentzian_fwhm = numerator_lorentzian / self.normalization;
407 let d_major = (numerator_major - value * self.d_normalization_d_major) / self.normalization;
408 let d_minor = (numerator_minor - value * self.d_normalization_d_minor) / self.normalization;
409 let (d_sample_over_radius, d_detector_over_radius) =
410 if self.geometry.sample_over_radius > self.geometry.detector_over_radius {
411 (d_major, d_minor)
412 } else if self.geometry.detector_over_radius > self.geometry.sample_over_radius {
413 (d_minor, d_major)
414 } else {
415 let equal = 0.5 * (d_major + d_minor);
416 (equal, equal)
417 };
418 FcjProfilePoint {
419 value,
420 d_position,
421 d_gaussian_fwhm,
422 d_lorentzian_fwhm,
423 d_sample_over_radius,
424 d_detector_over_radius,
425 }
426 }
427}
428
429fn quadrature_rule(axial_span_deg: f64, profile_fwhm_deg: f64) -> (&'static [f64], &'static [f64]) {
430 if axial_span_deg / profile_fwhm_deg <= SMALL_SPAN_RATIO_LIMIT {
431 (&SMALL_SPAN_QUADRATURE_NODES, &SMALL_SPAN_QUADRATURE_WEIGHTS)
432 } else {
433 (&QUADRATURE_NODES, &QUADRATURE_WEIGHTS)
434 }
435}
436
437#[allow(clippy::too_many_arguments)]
438fn prepare_node(
439 position_rad: f64,
440 height: f64,
441 coefficient: f64,
442 d_coefficient_d_major: f64,
443 d_coefficient_d_minor: f64,
444 d_height_d_major: f64,
445 d_height_d_minor: f64,
446) -> PreparedNode {
447 let square_root = (1.0 + height * height).sqrt();
448 let apparent_rad = (position_rad.cos() * square_root).acos();
449 let sine_apparent = apparent_rad.sin();
450 let d_apparent_d_height = -position_rad.cos() * height / (square_root * sine_apparent);
451 let d_apparent_d_position = position_rad.sin() * square_root / sine_apparent;
452 let geometry = (square_root * sine_apparent).recip();
453 let cotangent_apparent = apparent_rad.cos() / sine_apparent;
454 let d_geometry_d_height =
455 geometry * (-height / (1.0 + height * height) - cotangent_apparent * d_apparent_d_height);
456 let d_geometry_d_position =
457 geometry * -cotangent_apparent * d_apparent_d_position * DEGREE_TO_RADIAN;
458 PreparedNode {
459 apparent_position_deg: apparent_rad * RADIAN_TO_DEGREE,
460 weighted_geometry: coefficient * geometry,
461 d_weighted_geometry_d_position: coefficient * d_geometry_d_position,
462 d_weighted_geometry_d_major: d_coefficient_d_major * geometry
463 + coefficient * d_geometry_d_height * d_height_d_major,
464 d_weighted_geometry_d_minor: d_coefficient_d_minor * geometry
465 + coefficient * d_geometry_d_height * d_height_d_minor,
466 d_apparent_d_position,
467 d_apparent_d_major: d_apparent_d_height * RADIAN_TO_DEGREE * d_height_d_major,
468 d_apparent_d_minor: d_apparent_d_height * RADIAN_TO_DEGREE * d_height_d_minor,
469 }
470}
471
472fn validate_position(position_deg: f64) -> Result<(), FcjError> {
473 if !position_deg.is_finite() || position_deg <= 0.0 || position_deg >= 180.0 {
474 return Err(FcjError::InvalidPosition);
475 }
476 Ok(())
477}
478
479fn validate_geometry(geometry: FcjGeometry) -> Result<(), FcjError> {
480 if !geometry.sample_over_radius.is_finite()
481 || !geometry.detector_over_radius.is_finite()
482 || geometry.sample_over_radius < 0.0
483 || geometry.detector_over_radius < 0.0
484 {
485 return Err(FcjError::InvalidGeometry);
486 }
487 Ok(())
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn profile() -> FcjProfile {
495 FcjProfile::new(
496 12.0,
497 TchWidths {
498 gaussian_fwhm: 0.018,
499 lorentzian_fwhm: 0.006,
500 },
501 FcjGeometry {
502 sample_over_radius: 0.013,
503 detector_over_radius: 0.009,
504 },
505 )
506 .expect("valid FCJ profile")
507 }
508
509 fn assert_relative_close(actual: f64, expected: f64, tolerance: f64) {
510 let scale = actual.abs().max(expected.abs()).max(1.0);
511 assert!(
512 (actual - expected).abs() <= tolerance * scale,
513 "actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.1e}"
514 );
515 }
516
517 #[test]
518 fn zero_geometry_recovers_symmetric_tch_exactly() {
519 let position = 42.0;
520 let widths = TchWidths {
521 gaussian_fwhm: 0.04,
522 lorentzian_fwhm: 0.01,
523 };
524 let fcj = FcjProfile::new(
525 position,
526 widths,
527 FcjGeometry {
528 sample_over_radius: 0.0,
529 detector_over_radius: 0.0,
530 },
531 )
532 .expect("zero geometry");
533 assert_eq!(fcj.nodes.len(), 1);
534 let x = position + 0.017;
535 let expected = TchShape::from_component_fwhm(widths)
536 .expect("shape")
537 .evaluate(x - position);
538 let actual = fcj.evaluate(x);
539 assert_relative_close(actual.value, expected.value, 0.0);
540 assert_relative_close(actual.d_position, -expected.d_delta, 0.0);
541 assert_relative_close(actual.d_gaussian_fwhm, expected.d_gaussian_fwhm, 0.0);
542 assert_relative_close(actual.d_lorentzian_fwhm, expected.d_lorentzian_fwhm, 0.0);
543 assert_relative_close(actual.d_sample_over_radius, 0.0, 0.0);
544 assert_relative_close(actual.d_detector_over_radius, 0.0, 0.0);
545 }
546
547 #[test]
548 fn all_direct_derivatives_match_centered_differences() {
549 let x = 11.987;
550 let baseline = profile().evaluate(x);
551 let parameters = [12.0, 0.018, 0.006, 0.013, 0.009];
552 let steps = [1e-6, 1e-7, 1e-7, 1e-7, 1e-7];
553 let analytical = [
554 baseline.d_position,
555 baseline.d_gaussian_fwhm,
556 baseline.d_lorentzian_fwhm,
557 baseline.d_sample_over_radius,
558 baseline.d_detector_over_radius,
559 ];
560 for parameter in 0..parameters.len() {
561 let mut plus = parameters;
562 let mut minus = parameters;
563 plus[parameter] += steps[parameter];
564 minus[parameter] -= steps[parameter];
565 let evaluate = |values: [f64; 5]| {
566 FcjProfile::new(
567 values[0],
568 TchWidths {
569 gaussian_fwhm: values[1],
570 lorentzian_fwhm: values[2],
571 },
572 FcjGeometry {
573 sample_over_radius: values[3],
574 detector_over_radius: values[4],
575 },
576 )
577 .expect("perturbed profile")
578 .evaluate(x)
579 .value
580 };
581 let finite_difference = (evaluate(plus) - evaluate(minus)) / (2.0 * steps[parameter]);
582 assert_relative_close(analytical[parameter], finite_difference, 2e-6);
583 }
584 }
585
586 #[test]
587 fn support_union_reverses_above_ninety_degrees() {
588 let low = profile();
589 let high = FcjProfile::new(
590 138.0,
591 TchWidths {
592 gaussian_fwhm: 0.05,
593 lorentzian_fwhm: 0.02,
594 },
595 FcjGeometry {
596 sample_over_radius: 0.014,
597 detector_over_radius: 0.014,
598 },
599 )
600 .expect("high-angle profile");
601 let low_support = low.support_range(0.2);
602 let high_support = high.support_range(0.2);
603 assert!(low_support.left < 12.0 - 0.2);
604 assert_relative_close(low_support.right, 12.0 + 0.2, 1e-10);
605 assert_relative_close(high_support.left, 138.0 - 0.2, 1e-10);
606 assert!(high_support.right > 138.0 + 0.2);
607 }
608
609 #[test]
610 fn quadrature_order_tracks_axial_span_relative_to_peak_width() {
611 let broad_small_span = FcjProfile::new(
612 70.0,
613 TchWidths {
614 gaussian_fwhm: 0.035,
615 lorentzian_fwhm: 0.012,
616 },
617 FcjGeometry {
618 sample_over_radius: 0.016,
619 detector_over_radius: 0.009,
620 },
621 )
622 .expect("small-span profile");
623 assert_eq!(
624 broad_small_span.nodes.len(),
625 2 * SMALL_SPAN_QUADRATURE_ORDER
626 );
627
628 let narrow_large_span = profile();
629 assert_eq!(narrow_large_span.nodes.len(), 2 * QUADRATURE_ORDER);
630 }
631
632 #[test]
633 fn equal_heights_need_only_the_sloping_overlap_piece() {
634 let equal = FcjProfile::new(
635 70.0,
636 TchWidths {
637 gaussian_fwhm: 0.035,
638 lorentzian_fwhm: 0.012,
639 },
640 FcjGeometry {
641 sample_over_radius: 0.012,
642 detector_over_radius: 0.012,
643 },
644 )
645 .expect("equal-height profile");
646 assert_eq!(equal.nodes.len(), SMALL_SPAN_QUADRATURE_ORDER);
647 }
648}