Skip to main content

phasesmith_core/
fcj.rs

1//! Finger-Cox-Jephcoat axial-divergence convolution.
2
3use 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;
13// The convergence study in docs/fcj-profile.md bounds every value and direct
14// derivative against an independent 256-point integral at this ratio.
15const 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
39// Gauss-Legendre nodes and weights transformed from [-1, 1] to [0, 1].
40pub(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/// Dimensionless FCJ axial geometry.
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct FcjGeometry {
145    /// Sample axial half-height divided by diffractometer radius.
146    pub sample_over_radius: f64,
147    /// Receiving-slit axial half-height divided by diffractometer radius.
148    pub detector_over_radius: f64,
149}
150
151/// One FCJ-convolved TCH profile value and direct-input derivatives.
152#[derive(Clone, Copy, Debug, Default, PartialEq)]
153pub struct FcjProfilePoint {
154    /// Unit-area profile value before finite-support truncation.
155    pub value: f64,
156    /// Derivative with respect to the ideal Bragg position in degrees.
157    pub d_position: f64,
158    /// Derivative with respect to Gaussian component FWHM in degrees.
159    pub d_gaussian_fwhm: f64,
160    /// Derivative with respect to Lorentzian component FWHM in degrees.
161    pub d_lorentzian_fwhm: f64,
162    /// Derivative with respect to `sample_over_radius`.
163    pub d_sample_over_radius: f64,
164    /// Derivative with respect to `detector_over_radius`.
165    pub d_detector_over_radius: f64,
166}
167
168/// FCJ profile domain errors.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum FcjError {
171    /// Ideal Bragg position is non-finite or outside `(0, 180)` degrees.
172    InvalidPosition,
173    /// An axial geometry ratio is negative or non-finite.
174    InvalidGeometry,
175    /// The axial extent crosses the valid angular domain.
176    GeometryOutsideAngularDomain,
177    /// Component-width transformation failed.
178    InvalidWidths {
179        /// TCH component-width failure.
180        reason: TchError,
181    },
182    /// The transformed quadrature normalization is invalid.
183    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/// Precomputed FCJ geometry and TCH shape for repeated sample evaluation.
230#[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    /// Prepare one FCJ-convolved TCH profile.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`FcjError`] for invalid position, geometry, or component widths.
249    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            // Equal sample/detector heights have no flat overlap interval.
306            // Its separate major/minor derivatives are equal and opposite, so
307            // they also cancel in the symmetry-averaged public derivatives.
308            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    /// Evaluate the full FCJ-convolved profile at one sample coordinate.
357    #[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}