Skip to main content

phasesmith_core/
cw_contributions.rs

1//! CW accumulation with externally prepared sample-physics contributions.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::cw::{ConstantWavelengthInstrument, CwBatchError, CwError, CwProfileParameters};
7use crate::fcj::{FcjError, FcjGeometry, FcjProfile, FcjProfilePoint};
8use crate::profile::{
9    Accumulation, DenseJacobian, GridView, PatternDerivatives, ProfileError, SupportJacobian,
10    SupportPolicy, zeroed_f64_vec,
11};
12use crate::tch::{TchShape, TchWidths};
13use phasesmith_execution::ExecutionContext;
14
15const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
16const INSTRUMENT_PARAMETER_COUNT: usize = 5;
17const FCJ_PARAMETER_COUNT: usize = 2;
18const LOCAL_PARAMETER_COUNT: usize = 2;
19
20/// Validated sample-physics contributions for one CW reflection batch.
21#[derive(Clone, Copy, Debug)]
22pub struct CwContributionsView<'a> {
23    gaussian_variance_deg2: &'a [f64],
24    lorentzian_fwhm_deg: &'a [f64],
25    intensity_multiplier: &'a [f64],
26    d_gaussian_variance_d_position: &'a [f64],
27    d_lorentzian_fwhm_d_position: &'a [f64],
28    d_intensity_multiplier_d_position: &'a [f64],
29    d_gaussian_variance_d_parameters: &'a [f64],
30    d_lorentzian_fwhm_d_parameters: &'a [f64],
31    d_intensity_multiplier_d_parameters: &'a [f64],
32    parameter_count: usize,
33    reflection_count: usize,
34}
35
36/// Input arrays used to construct [`CwContributionsView`].
37#[derive(Clone, Copy, Debug)]
38pub struct CwContributionArrays<'a> {
39    /// Additive Gaussian variance for each reflection.
40    pub gaussian_variance_deg2: &'a [f64],
41    /// Additive Lorentzian FWHM for each reflection.
42    pub lorentzian_fwhm_deg: &'a [f64],
43    /// Multiplicative integrated-intensity correction for each reflection.
44    pub intensity_multiplier: &'a [f64],
45    /// Position derivative of the Gaussian-variance contribution.
46    pub d_gaussian_variance_d_position: &'a [f64],
47    /// Position derivative of the Lorentzian-FWHM contribution.
48    pub d_lorentzian_fwhm_d_position: &'a [f64],
49    /// Position derivative of the intensity multiplier.
50    pub d_intensity_multiplier_d_position: &'a [f64],
51    /// Parameter-major Gaussian-variance chains, flattened from `(parameter, reflection)`.
52    pub d_gaussian_variance_d_parameters: &'a [f64],
53    /// Parameter-major Lorentzian-FWHM chains, flattened from `(parameter, reflection)`.
54    pub d_lorentzian_fwhm_d_parameters: &'a [f64],
55    /// Parameter-major intensity-multiplier chains, flattened from `(parameter, reflection)`.
56    pub d_intensity_multiplier_d_parameters: &'a [f64],
57}
58
59/// Owned arrays used to construct [`OwnedCwContributions`].
60#[derive(Clone, Debug, Default, PartialEq)]
61pub struct OwnedCwContributionArrays {
62    /// Additive Gaussian variance for each reflection.
63    pub gaussian_variance_deg2: Vec<f64>,
64    /// Additive Lorentzian FWHM for each reflection.
65    pub lorentzian_fwhm_deg: Vec<f64>,
66    /// Multiplicative integrated-intensity correction for each reflection.
67    pub intensity_multiplier: Vec<f64>,
68    /// Position derivative of the Gaussian-variance contribution.
69    pub d_gaussian_variance_d_position: Vec<f64>,
70    /// Position derivative of the Lorentzian-FWHM contribution.
71    pub d_lorentzian_fwhm_d_position: Vec<f64>,
72    /// Position derivative of the intensity multiplier.
73    pub d_intensity_multiplier_d_position: Vec<f64>,
74    /// Parameter-major Gaussian-variance chains, flattened from `(parameter, reflection)`.
75    pub d_gaussian_variance_d_parameters: Vec<f64>,
76    /// Parameter-major Lorentzian-FWHM chains, flattened from `(parameter, reflection)`.
77    pub d_lorentzian_fwhm_d_parameters: Vec<f64>,
78    /// Parameter-major intensity-multiplier chains, flattened from `(parameter, reflection)`.
79    pub d_intensity_multiplier_d_parameters: Vec<f64>,
80}
81
82impl OwnedCwContributionArrays {
83    fn as_borrowed(&self) -> CwContributionArrays<'_> {
84        CwContributionArrays {
85            gaussian_variance_deg2: &self.gaussian_variance_deg2,
86            lorentzian_fwhm_deg: &self.lorentzian_fwhm_deg,
87            intensity_multiplier: &self.intensity_multiplier,
88            d_gaussian_variance_d_position: &self.d_gaussian_variance_d_position,
89            d_lorentzian_fwhm_d_position: &self.d_lorentzian_fwhm_d_position,
90            d_intensity_multiplier_d_position: &self.d_intensity_multiplier_d_position,
91            d_gaussian_variance_d_parameters: &self.d_gaussian_variance_d_parameters,
92            d_lorentzian_fwhm_d_parameters: &self.d_lorentzian_fwhm_d_parameters,
93            d_intensity_multiplier_d_parameters: &self.d_intensity_multiplier_d_parameters,
94        }
95    }
96}
97
98/// Validated owned sample-physics contributions for one CW reflection batch.
99#[derive(Clone, Debug, PartialEq)]
100pub struct OwnedCwContributions {
101    reflection_count: usize,
102    parameter_count: usize,
103    arrays: OwnedCwContributionArrays,
104}
105
106impl OwnedCwContributions {
107    /// Validate and take ownership of one reflection-batch contribution set.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`CwContributionsError`] for inconsistent lengths, non-finite
112    /// values, negative broadening, or negative intensity multipliers.
113    pub fn new(
114        reflection_count: usize,
115        parameter_count: usize,
116        arrays: OwnedCwContributionArrays,
117    ) -> Result<Self, CwContributionsError> {
118        CwContributionsView::new(reflection_count, parameter_count, arrays.as_borrowed())?;
119        Ok(Self {
120            reflection_count,
121            parameter_count,
122            arrays,
123        })
124    }
125
126    /// Construct neutral contributions with no provider parameters.
127    #[must_use]
128    pub fn neutral(reflection_count: usize) -> Self {
129        Self {
130            reflection_count,
131            parameter_count: 0,
132            arrays: OwnedCwContributionArrays {
133                gaussian_variance_deg2: vec![0.0; reflection_count],
134                lorentzian_fwhm_deg: vec![0.0; reflection_count],
135                intensity_multiplier: vec![1.0; reflection_count],
136                d_gaussian_variance_d_position: vec![0.0; reflection_count],
137                d_lorentzian_fwhm_d_position: vec![0.0; reflection_count],
138                d_intensity_multiplier_d_position: vec![0.0; reflection_count],
139                ..OwnedCwContributionArrays::default()
140            },
141        }
142    }
143
144    /// Borrow the owned arrays as a validated kernel input.
145    #[must_use]
146    pub fn as_view(&self) -> CwContributionsView<'_> {
147        CwContributionsView {
148            gaussian_variance_deg2: &self.arrays.gaussian_variance_deg2,
149            lorentzian_fwhm_deg: &self.arrays.lorentzian_fwhm_deg,
150            intensity_multiplier: &self.arrays.intensity_multiplier,
151            d_gaussian_variance_d_position: &self.arrays.d_gaussian_variance_d_position,
152            d_lorentzian_fwhm_d_position: &self.arrays.d_lorentzian_fwhm_d_position,
153            d_intensity_multiplier_d_position: &self.arrays.d_intensity_multiplier_d_position,
154            d_gaussian_variance_d_parameters: &self.arrays.d_gaussian_variance_d_parameters,
155            d_lorentzian_fwhm_d_parameters: &self.arrays.d_lorentzian_fwhm_d_parameters,
156            d_intensity_multiplier_d_parameters: &self.arrays.d_intensity_multiplier_d_parameters,
157            parameter_count: self.parameter_count,
158            reflection_count: self.reflection_count,
159        }
160    }
161
162    /// Number of reflections represented by this batch.
163    #[must_use]
164    pub const fn reflection_count(&self) -> usize {
165        self.reflection_count
166    }
167
168    /// Number of named provider parameters.
169    #[must_use]
170    pub const fn parameter_count(&self) -> usize {
171        self.parameter_count
172    }
173
174    /// Borrow the owned contribution arrays.
175    #[must_use]
176    pub const fn arrays(&self) -> &OwnedCwContributionArrays {
177        &self.arrays
178    }
179}
180
181fn validate_reflection_arrays(
182    reflection_count: usize,
183    arrays: CwContributionArrays<'_>,
184) -> Result<(), CwContributionsError> {
185    for (name, values) in [
186        ("gaussian_variance_deg2", arrays.gaussian_variance_deg2),
187        ("lorentzian_fwhm_deg", arrays.lorentzian_fwhm_deg),
188        ("intensity_multiplier", arrays.intensity_multiplier),
189        (
190            "d_gaussian_variance_d_position",
191            arrays.d_gaussian_variance_d_position,
192        ),
193        (
194            "d_lorentzian_fwhm_d_position",
195            arrays.d_lorentzian_fwhm_d_position,
196        ),
197        (
198            "d_intensity_multiplier_d_position",
199            arrays.d_intensity_multiplier_d_position,
200        ),
201    ] {
202        if values.len() != reflection_count {
203            return Err(CwContributionsError::LengthMismatch { name });
204        }
205    }
206    for reflection in 0..reflection_count {
207        for (quantity, value, non_negative) in [
208            (
209                "gaussian_variance_deg2",
210                arrays.gaussian_variance_deg2[reflection],
211                true,
212            ),
213            (
214                "lorentzian_fwhm_deg",
215                arrays.lorentzian_fwhm_deg[reflection],
216                true,
217            ),
218            (
219                "intensity_multiplier",
220                arrays.intensity_multiplier[reflection],
221                true,
222            ),
223            (
224                "d_gaussian_variance_d_position",
225                arrays.d_gaussian_variance_d_position[reflection],
226                false,
227            ),
228            (
229                "d_lorentzian_fwhm_d_position",
230                arrays.d_lorentzian_fwhm_d_position[reflection],
231                false,
232            ),
233            (
234                "d_intensity_multiplier_d_position",
235                arrays.d_intensity_multiplier_d_position[reflection],
236                false,
237            ),
238        ] {
239            if !value.is_finite() || (non_negative && value < 0.0) {
240                return Err(CwContributionsError::InvalidContribution {
241                    reflection,
242                    quantity,
243                });
244            }
245        }
246    }
247    Ok(())
248}
249
250fn validate_parameter_arrays(
251    reflection_count: usize,
252    parameter_count: usize,
253    arrays: CwContributionArrays<'_>,
254) -> Result<(), CwContributionsError> {
255    let derivative_count = parameter_count
256        .checked_mul(reflection_count)
257        .ok_or(CwContributionsError::AllocationOverflow)?;
258    let derivative_arrays = [
259        (
260            "d_gaussian_variance_d_parameters",
261            arrays.d_gaussian_variance_d_parameters,
262        ),
263        (
264            "d_lorentzian_fwhm_d_parameters",
265            arrays.d_lorentzian_fwhm_d_parameters,
266        ),
267        (
268            "d_intensity_multiplier_d_parameters",
269            arrays.d_intensity_multiplier_d_parameters,
270        ),
271    ];
272    for (name, values) in derivative_arrays {
273        if values.len() != derivative_count {
274            return Err(CwContributionsError::LengthMismatch { name });
275        }
276        if let Some(index) = values.iter().position(|value| !value.is_finite()) {
277            return Err(CwContributionsError::InvalidDerivative {
278                parameter: index / reflection_count.max(1),
279                reflection: index % reflection_count.max(1),
280                quantity: name,
281            });
282        }
283    }
284    Ok(())
285}
286
287impl<'a> CwContributionsView<'a> {
288    /// Validate and borrow one reflection-batch contribution set.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`CwContributionsError`] for inconsistent lengths, non-finite
293    /// values, negative broadening, or negative intensity multipliers.
294    pub fn new(
295        reflection_count: usize,
296        parameter_count: usize,
297        arrays: CwContributionArrays<'a>,
298    ) -> Result<Self, CwContributionsError> {
299        validate_reflection_arrays(reflection_count, arrays)?;
300        validate_parameter_arrays(reflection_count, parameter_count, arrays)?;
301        Ok(Self {
302            gaussian_variance_deg2: arrays.gaussian_variance_deg2,
303            lorentzian_fwhm_deg: arrays.lorentzian_fwhm_deg,
304            intensity_multiplier: arrays.intensity_multiplier,
305            d_gaussian_variance_d_position: arrays.d_gaussian_variance_d_position,
306            d_lorentzian_fwhm_d_position: arrays.d_lorentzian_fwhm_d_position,
307            d_intensity_multiplier_d_position: arrays.d_intensity_multiplier_d_position,
308            d_gaussian_variance_d_parameters: arrays.d_gaussian_variance_d_parameters,
309            d_lorentzian_fwhm_d_parameters: arrays.d_lorentzian_fwhm_d_parameters,
310            d_intensity_multiplier_d_parameters: arrays.d_intensity_multiplier_d_parameters,
311            parameter_count,
312            reflection_count,
313        })
314    }
315
316    /// Number of named provider parameters.
317    #[must_use]
318    pub const fn parameter_count(self) -> usize {
319        self.parameter_count
320    }
321
322    const fn derivative_index(self, parameter: usize, reflection: usize) -> usize {
323        parameter * self.reflection_count + reflection
324    }
325}
326
327/// Errors while validating or accumulating external CW contributions.
328#[derive(Clone, Debug, PartialEq, Eq)]
329pub enum CwContributionsError {
330    /// One contribution array has an inconsistent length.
331    LengthMismatch {
332        /// Stable array name.
333        name: &'static str,
334    },
335    /// A per-reflection contribution is non-finite or outside its domain.
336    InvalidContribution {
337        /// Reflection index.
338        reflection: usize,
339        /// Stable quantity name.
340        quantity: &'static str,
341    },
342    /// A parameter derivative is non-finite.
343    InvalidDerivative {
344        /// Provider parameter row.
345        parameter: usize,
346        /// Reflection index.
347        reflection: usize,
348        /// Stable derivative-array name.
349        quantity: &'static str,
350    },
351    /// Reflection or instrument input is invalid.
352    Cw {
353        /// Underlying CW error.
354        reason: CwBatchError,
355    },
356    /// One FCJ profile could not be prepared.
357    Fcj {
358        /// Reflection index.
359        reflection: usize,
360        /// Underlying FCJ error.
361        reason: FcjError,
362    },
363    /// Allocation size arithmetic overflowed.
364    AllocationOverflow,
365    /// Generic grid, support, or allocation failure.
366    Profile {
367        /// Underlying profile error.
368        reason: ProfileError,
369    },
370}
371
372impl Display for CwContributionsError {
373    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
374        match self {
375            Self::LengthMismatch { name } => write!(formatter, "{name} has an inconsistent length"),
376            Self::InvalidContribution {
377                reflection,
378                quantity,
379            } => write!(
380                formatter,
381                "reflection {reflection} has an invalid {quantity} contribution"
382            ),
383            Self::InvalidDerivative {
384                parameter,
385                reflection,
386                quantity,
387            } => write!(
388                formatter,
389                "provider parameter {parameter}, reflection {reflection} has an invalid {quantity} derivative"
390            ),
391            Self::Cw { reason } => Display::fmt(reason, formatter),
392            Self::Fcj { reflection, reason } => {
393                write!(
394                    formatter,
395                    "reflection {reflection} has invalid FCJ geometry: {reason}"
396                )
397            }
398            Self::AllocationOverflow => write!(formatter, "contribution allocation size overflow"),
399            Self::Profile { reason } => Display::fmt(reason, formatter),
400        }
401    }
402}
403
404impl Error for CwContributionsError {}
405
406impl From<ProfileError> for CwContributionsError {
407    fn from(reason: ProfileError) -> Self {
408        Self::Profile { reason }
409    }
410}
411
412#[derive(Clone, Debug)]
413struct PreparedProfile {
414    tch: TchShape,
415    fcj: Option<FcjProfile>,
416    support_radius_deg: f64,
417    d_gaussian_d_instrument: [f64; INSTRUMENT_PARAMETER_COUNT],
418    d_lorentzian_d_instrument: [f64; INSTRUMENT_PARAMETER_COUNT],
419    d_gaussian_d_position: f64,
420    d_lorentzian_d_position: f64,
421    d_gaussian_d_variance: f64,
422}
423
424impl PreparedProfile {
425    fn evaluate(&self, x_deg: f64, position_deg: f64) -> FcjProfilePoint {
426        if let Some(fcj) = &self.fcj {
427            return fcj.evaluate_supported(x_deg, self.support_radius_deg);
428        }
429        let point = self.tch.evaluate(x_deg - position_deg);
430        FcjProfilePoint {
431            value: point.value,
432            d_position: -point.d_delta,
433            d_gaussian_fwhm: point.d_gaussian_fwhm,
434            d_lorentzian_fwhm: point.d_lorentzian_fwhm,
435            d_sample_over_radius: 0.0,
436            d_detector_over_radius: 0.0,
437        }
438    }
439}
440
441struct PreparedBatch {
442    profiles: Vec<PreparedProfile>,
443    starts: Vec<usize>,
444    offsets: Vec<usize>,
445}
446
447struct ReflectionBlock {
448    start: usize,
449    y: Vec<f64>,
450    local: Vec<f64>,
451    global: Vec<f64>,
452}
453
454fn prepare_profile(
455    reflection: usize,
456    position: f64,
457    instrument: ConstantWavelengthInstrument,
458    contributions: CwContributionsView<'_>,
459    geometry: Option<FcjGeometry>,
460    support: SupportPolicy,
461) -> Result<PreparedProfile, CwContributionsError> {
462    let base =
463        CwProfileParameters::from_validated_instrument(position, instrument).map_err(|reason| {
464            CwContributionsError::Cw {
465                reason: CwBatchError::InvalidReflection { reflection, reason },
466            }
467        })?;
468    let variance = base.gaussian_variance_deg2 + contributions.gaussian_variance_deg2[reflection];
469    if !variance.is_finite() || variance <= 0.0 {
470        return Err(CwContributionsError::Cw {
471            reason: CwBatchError::InvalidReflection {
472                reflection,
473                reason: CwError::NonPositiveGaussianVariance,
474            },
475        });
476    }
477    let gaussian = GAUSSIAN_FWHM_PER_SIGMA * variance.sqrt();
478    let lorentzian = base.lorentzian_fwhm_deg + contributions.lorentzian_fwhm_deg[reflection];
479    let tch = TchShape::from_component_fwhm(TchWidths {
480        gaussian_fwhm: gaussian,
481        lorentzian_fwhm: lorentzian,
482    })
483    .map_err(|_| CwContributionsError::Cw {
484        reason: CwBatchError::InvalidReflection {
485            reflection,
486            reason: CwError::InvalidTchTransform,
487        },
488    })?;
489    let instrument_gaussian_scale = base.gaussian_fwhm_deg / gaussian;
490    let d_gaussian_d_instrument = base
491        .d_gaussian_fwhm_d_instrument
492        .map(|value| value * instrument_gaussian_scale);
493    let d_gaussian_d_variance = GAUSSIAN_FWHM_PER_SIGMA / (2.0 * variance.sqrt());
494    let support_radius_deg = support.radius(tch.total_fwhm);
495    let fcj = geometry
496        .map(|geometry| {
497            FcjProfile::new(
498                position,
499                TchWidths {
500                    gaussian_fwhm: gaussian,
501                    lorentzian_fwhm: lorentzian,
502                },
503                geometry,
504            )
505            .map_err(|reason| CwContributionsError::Fcj { reflection, reason })
506        })
507        .transpose()?;
508    Ok(PreparedProfile {
509        tch,
510        fcj,
511        support_radius_deg,
512        d_gaussian_d_instrument,
513        d_lorentzian_d_instrument: base.d_lorentzian_fwhm_d_instrument,
514        d_gaussian_d_position: base.d_gaussian_fwhm_d_two_theta * instrument_gaussian_scale
515            + d_gaussian_d_variance * contributions.d_gaussian_variance_d_position[reflection],
516        d_lorentzian_d_position: base.d_lorentzian_fwhm_d_two_theta
517            + contributions.d_lorentzian_fwhm_d_position[reflection],
518        d_gaussian_d_variance,
519    })
520}
521
522fn prepare_batch(
523    x: &[f64],
524    positions_deg: &[f64],
525    instrument: ConstantWavelengthInstrument,
526    contributions: CwContributionsView<'_>,
527    geometry: Option<FcjGeometry>,
528    support: SupportPolicy,
529) -> Result<PreparedBatch, CwContributionsError> {
530    let reflection_count = positions_deg.len();
531    let mut profiles = Vec::new();
532    let mut starts: Vec<usize> = Vec::new();
533    let mut offsets: Vec<usize> = Vec::new();
534    profiles
535        .try_reserve_exact(reflection_count)
536        .map_err(|_| CwContributionsError::AllocationOverflow)?;
537    starts
538        .try_reserve_exact(reflection_count)
539        .map_err(|_| CwContributionsError::AllocationOverflow)?;
540    offsets
541        .try_reserve_exact(
542            reflection_count
543                .checked_add(1)
544                .ok_or(CwContributionsError::AllocationOverflow)?,
545        )
546        .map_err(|_| CwContributionsError::AllocationOverflow)?;
547    offsets.push(0);
548    for reflection in 0..reflection_count {
549        let profile = prepare_profile(
550            reflection,
551            positions_deg[reflection],
552            instrument,
553            contributions,
554            geometry,
555            support,
556        )?;
557        let range = match &profile.fcj {
558            Some(fcj) => fcj.support_range(profile.support_radius_deg),
559            None => support.range(positions_deg[reflection], profile.tch.total_fwhm),
560        };
561        let lower = x.partition_point(|value| *value < range.left);
562        let upper = x.partition_point(|value| *value <= range.right);
563        offsets.push(
564            offsets[reflection]
565                .checked_add(upper - lower)
566                .ok_or(CwContributionsError::AllocationOverflow)?,
567        );
568        profiles.push(profile);
569        starts.push(lower);
570    }
571    Ok(PreparedBatch {
572        profiles,
573        starts,
574        offsets,
575    })
576}
577
578/// Accumulate CW reflections with vectorized sample-physics contributions.
579///
580/// Local derivative order is base integrated intensity and position. Dense
581/// global order is U/V/W/X/Y followed by the provider parameter rows.
582///
583/// # Errors
584///
585/// Returns [`CwContributionsError`] for invalid inputs or derived profiles.
586pub fn accumulate_cw_contributions_batch(
587    grid: GridView<'_>,
588    positions_deg: &[f64],
589    base_intensities: &[f64],
590    instrument: ConstantWavelengthInstrument,
591    contributions: CwContributionsView<'_>,
592    support: SupportPolicy,
593) -> Result<Accumulation, CwContributionsError> {
594    accumulate_cw_contributions_batch_with_context(
595        grid,
596        positions_deg,
597        base_intensities,
598        instrument,
599        contributions,
600        support,
601        &ExecutionContext::serial(),
602    )
603}
604
605/// Accumulate symmetric CW contributions with a bounded execution context.
606///
607/// # Errors
608///
609/// Returns [`CwContributionsError`] for invalid inputs or derived profiles.
610pub fn accumulate_cw_contributions_batch_with_context(
611    grid: GridView<'_>,
612    positions_deg: &[f64],
613    base_intensities: &[f64],
614    instrument: ConstantWavelengthInstrument,
615    contributions: CwContributionsView<'_>,
616    support: SupportPolicy,
617    execution: &ExecutionContext,
618) -> Result<Accumulation, CwContributionsError> {
619    accumulate_cw_contributions_impl(
620        grid,
621        positions_deg,
622        base_intensities,
623        instrument,
624        contributions,
625        None,
626        support,
627        execution,
628    )
629}
630
631/// Accumulate FCJ-asymmetric CW reflections with vectorized sample physics.
632///
633/// Local derivative order is base integrated intensity and ideal position.
634/// Dense global order is U/V/W/X/Y, the sample and detector axial ratios,
635/// followed by provider parameter rows.
636///
637/// # Errors
638///
639/// Returns [`CwContributionsError`] for invalid inputs or derived profiles.
640pub fn accumulate_cw_fcj_contributions_batch(
641    grid: GridView<'_>,
642    positions_deg: &[f64],
643    base_intensities: &[f64],
644    instrument: ConstantWavelengthInstrument,
645    contributions: CwContributionsView<'_>,
646    geometry: FcjGeometry,
647    support: SupportPolicy,
648) -> Result<Accumulation, CwContributionsError> {
649    accumulate_cw_fcj_contributions_batch_with_context(
650        grid,
651        positions_deg,
652        base_intensities,
653        instrument,
654        contributions,
655        geometry,
656        support,
657        &ExecutionContext::serial(),
658    )
659}
660
661/// Accumulate FCJ-asymmetric contributions with a bounded execution context.
662///
663/// # Errors
664///
665/// Returns [`CwContributionsError`] for invalid inputs or derived profiles.
666#[allow(clippy::too_many_arguments)]
667pub fn accumulate_cw_fcj_contributions_batch_with_context(
668    grid: GridView<'_>,
669    positions_deg: &[f64],
670    base_intensities: &[f64],
671    instrument: ConstantWavelengthInstrument,
672    contributions: CwContributionsView<'_>,
673    geometry: FcjGeometry,
674    support: SupportPolicy,
675    execution: &ExecutionContext,
676) -> Result<Accumulation, CwContributionsError> {
677    accumulate_cw_contributions_impl(
678        grid,
679        positions_deg,
680        base_intensities,
681        instrument,
682        contributions,
683        Some(geometry),
684        support,
685        execution,
686    )
687}
688
689#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
690fn accumulate_cw_contributions_impl(
691    grid: GridView<'_>,
692    positions_deg: &[f64],
693    base_intensities: &[f64],
694    instrument: ConstantWavelengthInstrument,
695    contributions: CwContributionsView<'_>,
696    geometry: Option<FcjGeometry>,
697    support: SupportPolicy,
698    execution: &ExecutionContext,
699) -> Result<Accumulation, CwContributionsError> {
700    support.validate()?;
701    let reflections = crate::cw::CwReflectionBatchView::new(positions_deg, base_intensities)
702        .map_err(|reason| CwContributionsError::Cw { reason })?;
703    instrument
704        .validate()
705        .map_err(|reason| CwContributionsError::Cw {
706            reason: CwBatchError::InvalidInstrument { reason },
707        })?;
708    if contributions.reflection_count != reflections.len() {
709        return Err(CwContributionsError::LengthMismatch {
710            name: "contribution reflection count",
711        });
712    }
713    let x = grid.as_slice();
714    let reflection_count = reflections.len();
715    let axial_parameter_count = if geometry.is_some() {
716        FCJ_PARAMETER_COUNT
717    } else {
718        0
719    };
720    let global_parameter_count = INSTRUMENT_PARAMETER_COUNT
721        .checked_add(axial_parameter_count)
722        .ok_or(CwContributionsError::AllocationOverflow)?
723        .checked_add(contributions.parameter_count)
724        .ok_or(CwContributionsError::AllocationOverflow)?;
725    let prepared = prepare_batch(
726        x,
727        positions_deg,
728        instrument,
729        contributions,
730        geometry,
731        support,
732    )?;
733
734    let active_count = prepared.offsets.last().copied().unwrap_or(0);
735    let local_count = active_count
736        .checked_mul(LOCAL_PARAMETER_COUNT)
737        .ok_or(CwContributionsError::AllocationOverflow)?;
738    let global_count = global_parameter_count
739        .checked_mul(x.len())
740        .ok_or(CwContributionsError::AllocationOverflow)?;
741    let mut y = zeroed_f64_vec(x.len())?;
742    let mut local_values = zeroed_f64_vec(local_count)?;
743    let mut global_values = zeroed_f64_vec(global_count)?;
744    if execution.threads() == 1 || reflection_count < 16 {
745        for reflection in 0..reflection_count {
746            let profile = &prepared.profiles[reflection];
747            let base_intensity = base_intensities[reflection];
748            let multiplier = contributions.intensity_multiplier[reflection];
749            let effective_intensity = base_intensity * multiplier;
750            if !effective_intensity.is_finite() {
751                return Err(CwContributionsError::InvalidContribution {
752                    reflection,
753                    quantity: "effective_intensity",
754                });
755            }
756            let begin = prepared.offsets[reflection];
757            let end = prepared.offsets[reflection + 1];
758            for active in begin..end {
759                let sample = prepared.starts[reflection] + active - begin;
760                let point = profile.evaluate(x[sample], positions_deg[reflection]);
761                y[sample] += effective_intensity * point.value;
762                let local = active * LOCAL_PARAMETER_COUNT;
763                local_values[local] = multiplier * point.value;
764                local_values[local + 1] = base_intensity
765                    * (contributions.d_intensity_multiplier_d_position[reflection] * point.value
766                        + multiplier
767                            * (point.d_position
768                                + point.d_gaussian_fwhm * profile.d_gaussian_d_position
769                                + point.d_lorentzian_fwhm * profile.d_lorentzian_d_position));
770                for parameter in 0..INSTRUMENT_PARAMETER_COUNT {
771                    let derivative = point.d_gaussian_fwhm
772                        * profile.d_gaussian_d_instrument[parameter]
773                        + point.d_lorentzian_fwhm * profile.d_lorentzian_d_instrument[parameter];
774                    global_values[parameter * x.len() + sample] += effective_intensity * derivative;
775                }
776                if geometry.is_some() {
777                    global_values[INSTRUMENT_PARAMETER_COUNT * x.len() + sample] +=
778                        effective_intensity * point.d_sample_over_radius;
779                    global_values[(INSTRUMENT_PARAMETER_COUNT + 1) * x.len() + sample] +=
780                        effective_intensity * point.d_detector_over_radius;
781                }
782                for parameter in 0..contributions.parameter_count {
783                    let index = contributions.derivative_index(parameter, reflection);
784                    let d_multiplier = contributions.d_intensity_multiplier_d_parameters[index];
785                    let d_gaussian = profile.d_gaussian_d_variance
786                        * contributions.d_gaussian_variance_d_parameters[index];
787                    let d_lorentzian = contributions.d_lorentzian_fwhm_d_parameters[index];
788                    let derivative = base_intensity
789                        * (d_multiplier * point.value
790                            + multiplier
791                                * (point.d_gaussian_fwhm * d_gaussian
792                                    + point.d_lorentzian_fwhm * d_lorentzian));
793                    global_values[(INSTRUMENT_PARAMETER_COUNT
794                        + axial_parameter_count
795                        + parameter)
796                        * x.len()
797                        + sample] += derivative;
798                }
799            }
800        }
801    } else {
802        let blocks = execution.map_ordered(reflection_count, 16, |reflection| {
803            let profile = &prepared.profiles[reflection];
804            let base_intensity = base_intensities[reflection];
805            let multiplier = contributions.intensity_multiplier[reflection];
806            let effective_intensity = base_intensity * multiplier;
807            if !effective_intensity.is_finite() {
808                return Err(CwContributionsError::InvalidContribution {
809                    reflection,
810                    quantity: "effective_intensity",
811                });
812            }
813            let begin = prepared.offsets[reflection];
814            let end = prepared.offsets[reflection + 1];
815            let support_count = end - begin;
816            let mut block = ReflectionBlock {
817                start: prepared.starts[reflection],
818                y: zeroed_f64_vec(support_count)?,
819                local: zeroed_f64_vec(
820                    support_count
821                        .checked_mul(LOCAL_PARAMETER_COUNT)
822                        .ok_or(CwContributionsError::AllocationOverflow)?,
823                )?,
824                global: zeroed_f64_vec(
825                    support_count
826                        .checked_mul(global_parameter_count)
827                        .ok_or(CwContributionsError::AllocationOverflow)?,
828                )?,
829            };
830            for support_index in 0..support_count {
831                let sample = block.start + support_index;
832                let point = profile.evaluate(x[sample], positions_deg[reflection]);
833                block.y[support_index] = effective_intensity * point.value;
834                let local = support_index * LOCAL_PARAMETER_COUNT;
835                block.local[local] = multiplier * point.value;
836                block.local[local + 1] = base_intensity
837                    * (contributions.d_intensity_multiplier_d_position[reflection] * point.value
838                        + multiplier
839                            * (point.d_position
840                                + point.d_gaussian_fwhm * profile.d_gaussian_d_position
841                                + point.d_lorentzian_fwhm * profile.d_lorentzian_d_position));
842                for parameter in 0..INSTRUMENT_PARAMETER_COUNT {
843                    let derivative = point.d_gaussian_fwhm
844                        * profile.d_gaussian_d_instrument[parameter]
845                        + point.d_lorentzian_fwhm * profile.d_lorentzian_d_instrument[parameter];
846                    block.global[parameter * support_count + support_index] =
847                        effective_intensity * derivative;
848                }
849                if geometry.is_some() {
850                    block.global[INSTRUMENT_PARAMETER_COUNT * support_count + support_index] =
851                        effective_intensity * point.d_sample_over_radius;
852                    block.global
853                        [(INSTRUMENT_PARAMETER_COUNT + 1) * support_count + support_index] =
854                        effective_intensity * point.d_detector_over_radius;
855                }
856                for parameter in 0..contributions.parameter_count {
857                    let index = contributions.derivative_index(parameter, reflection);
858                    let d_multiplier = contributions.d_intensity_multiplier_d_parameters[index];
859                    let d_gaussian = profile.d_gaussian_d_variance
860                        * contributions.d_gaussian_variance_d_parameters[index];
861                    let d_lorentzian = contributions.d_lorentzian_fwhm_d_parameters[index];
862                    let derivative = base_intensity
863                        * (d_multiplier * point.value
864                            + multiplier
865                                * (point.d_gaussian_fwhm * d_gaussian
866                                    + point.d_lorentzian_fwhm * d_lorentzian));
867                    block.global[(INSTRUMENT_PARAMETER_COUNT
868                        + axial_parameter_count
869                        + parameter)
870                        * support_count
871                        + support_index] = derivative;
872                }
873            }
874            Ok(block)
875        });
876        for (reflection, block) in blocks.into_iter().enumerate() {
877            let block = block?;
878            let begin = prepared.offsets[reflection];
879            let support_count = block.y.len();
880            let local_begin = begin * LOCAL_PARAMETER_COUNT;
881            let local_end = local_begin + block.local.len();
882            local_values[local_begin..local_end].copy_from_slice(&block.local);
883            for support_index in 0..support_count {
884                let sample = block.start + support_index;
885                y[sample] += block.y[support_index];
886                for parameter in 0..global_parameter_count {
887                    global_values[parameter * x.len() + sample] +=
888                        block.global[parameter * support_count + support_index];
889                }
890            }
891        }
892    }
893    Ok(Accumulation {
894        y,
895        derivatives: PatternDerivatives {
896            local: SupportJacobian {
897                starts: prepared.starts,
898                offsets: prepared.offsets,
899                values: local_values,
900                parameter_count: LOCAL_PARAMETER_COUNT,
901            },
902            global: Some(DenseJacobian {
903                values: global_values,
904                parameter_count: global_parameter_count,
905                sample_count: x.len(),
906            }),
907        },
908        sample_count: x.len(),
909    })
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use crate::cw::{CwReflectionBatchView, accumulate_cw_batch};
916
917    fn instrument() -> ConstantWavelengthInstrument {
918        ConstantWavelengthInstrument {
919            wavelength_angstrom: 1.540_56,
920            u_deg2: 2.0e-4,
921            v_deg2: -1.0e-4,
922            w_deg2: 1.2e-4,
923            x_deg: 1.5e-3,
924            y_deg: 3.0e-3,
925        }
926    }
927
928    #[test]
929    fn neutral_contributions_match_the_instrument_path_exactly() {
930        let x: Vec<f64> = (0..=2_000)
931            .map(|index| 39.0 + f64::from(index) * 0.001)
932            .collect();
933        let positions = [39.8, 40.2];
934        let intensities = [12.0, 7.0];
935        let zeros = [0.0, 0.0];
936        let ones = [1.0, 1.0];
937        let arrays = CwContributionArrays {
938            gaussian_variance_deg2: &zeros,
939            lorentzian_fwhm_deg: &zeros,
940            intensity_multiplier: &ones,
941            d_gaussian_variance_d_position: &zeros,
942            d_lorentzian_fwhm_d_position: &zeros,
943            d_intensity_multiplier_d_position: &zeros,
944            d_gaussian_variance_d_parameters: &[],
945            d_lorentzian_fwhm_d_parameters: &[],
946            d_intensity_multiplier_d_parameters: &[],
947        };
948        let contributions = CwContributionsView::new(2, 0, arrays).expect("neutral");
949        let grid = GridView::new(&x).expect("grid");
950        let support = SupportPolicy::FwhmMultiple(20.0);
951        let expected = accumulate_cw_batch(
952            grid,
953            CwReflectionBatchView::new(&positions, &intensities).expect("reflections"),
954            instrument(),
955            support,
956        )
957        .expect("CW");
958        let actual = accumulate_cw_contributions_batch(
959            grid,
960            &positions,
961            &intensities,
962            instrument(),
963            contributions,
964            support,
965        )
966        .expect("contributions");
967        assert_eq!(actual, expected);
968    }
969
970    #[test]
971    fn symmetric_and_fcj_blocks_are_bitwise_identical_across_worker_counts() {
972        let x = (0..=30_000)
973            .map(|index| 20.0 + f64::from(index) * 0.003)
974            .collect::<Vec<_>>();
975        let positions = (0..48)
976            .map(|index| 25.0 + f64::from(index) * 1.6)
977            .collect::<Vec<_>>();
978        let intensities = (0..48)
979            .map(|index| 5.0 + 0.2 * f64::from(index))
980            .collect::<Vec<_>>();
981        let zero = vec![0.0; positions.len()];
982        let one = vec![1.0; positions.len()];
983        let provider = (0..48)
984            .map(|index| 1.0e-5 * f64::from(index + 1))
985            .collect::<Vec<_>>();
986        let arrays = CwContributionArrays {
987            gaussian_variance_deg2: &zero,
988            lorentzian_fwhm_deg: &zero,
989            intensity_multiplier: &one,
990            d_gaussian_variance_d_position: &zero,
991            d_lorentzian_fwhm_d_position: &zero,
992            d_intensity_multiplier_d_position: &zero,
993            d_gaussian_variance_d_parameters: &provider,
994            d_lorentzian_fwhm_d_parameters: &zero,
995            d_intensity_multiplier_d_parameters: &zero,
996        };
997        let contributions =
998            CwContributionsView::new(positions.len(), 1, arrays).expect("contributions");
999        let grid = GridView::new(&x).expect("grid");
1000        let support = SupportPolicy::FwhmMultiple(20.0);
1001        let serial = ExecutionContext::serial();
1002        let two = ExecutionContext::new(2).expect("two threads");
1003        let three = ExecutionContext::new(3).expect("three threads");
1004
1005        let expected = accumulate_cw_contributions_batch_with_context(
1006            grid,
1007            &positions,
1008            &intensities,
1009            instrument(),
1010            contributions,
1011            support,
1012            &serial,
1013        )
1014        .expect("serial symmetric");
1015        let expected_fcj = accumulate_cw_fcj_contributions_batch_with_context(
1016            grid,
1017            &positions,
1018            &intensities,
1019            instrument(),
1020            contributions,
1021            FcjGeometry {
1022                sample_over_radius: 0.002,
1023                detector_over_radius: 0.003,
1024            },
1025            support,
1026            &serial,
1027        )
1028        .expect("serial FCJ");
1029        for context in [&two, &three] {
1030            assert_eq!(
1031                accumulate_cw_contributions_batch_with_context(
1032                    grid,
1033                    &positions,
1034                    &intensities,
1035                    instrument(),
1036                    contributions,
1037                    support,
1038                    context,
1039                )
1040                .expect("parallel symmetric"),
1041                expected
1042            );
1043            assert_eq!(
1044                accumulate_cw_fcj_contributions_batch_with_context(
1045                    grid,
1046                    &positions,
1047                    &intensities,
1048                    instrument(),
1049                    contributions,
1050                    FcjGeometry {
1051                        sample_over_radius: 0.002,
1052                        detector_over_radius: 0.003,
1053                    },
1054                    support,
1055                    context,
1056                )
1057                .expect("parallel FCJ"),
1058                expected_fcj
1059            );
1060        }
1061    }
1062
1063    #[test]
1064    fn position_and_provider_derivatives_match_centered_differences() {
1065        let x: Vec<f64> = (0..=2_000)
1066            .map(|index| 49.5 + f64::from(index) * 0.000_5)
1067            .collect();
1068        let intensity = [8.0];
1069        let support = SupportPolicy::FwhmMultiple(100.0);
1070        let calculate = |position: f64, amplitude: f64| {
1071            let normalized = position / 100.0;
1072            let variance = [amplitude * normalized * normalized];
1073            let zeros = [0.0];
1074            let ones = [1.0];
1075            let d_variance_d_position = [2.0 * amplitude * normalized / 100.0];
1076            let d_variance_d_amplitude = [normalized * normalized];
1077            let arrays = CwContributionArrays {
1078                gaussian_variance_deg2: &variance,
1079                lorentzian_fwhm_deg: &zeros,
1080                intensity_multiplier: &ones,
1081                d_gaussian_variance_d_position: &d_variance_d_position,
1082                d_lorentzian_fwhm_d_position: &zeros,
1083                d_intensity_multiplier_d_position: &zeros,
1084                d_gaussian_variance_d_parameters: &d_variance_d_amplitude,
1085                d_lorentzian_fwhm_d_parameters: &zeros,
1086                d_intensity_multiplier_d_parameters: &zeros,
1087            };
1088            accumulate_cw_contributions_batch(
1089                GridView::new(&x).expect("grid"),
1090                &[position],
1091                &intensity,
1092                instrument(),
1093                CwContributionsView::new(1, 1, arrays).expect("contributions"),
1094                support,
1095            )
1096            .expect("contribution accumulation")
1097        };
1098
1099        let position = 50.0;
1100        let amplitude = 3.0e-4;
1101        let baseline = calculate(position, amplitude);
1102        let position_step = 1.0e-6;
1103        let position_plus = calculate(position + position_step, amplitude);
1104        let position_minus = calculate(position - position_step, amplitude);
1105        let dense = baseline
1106            .derivatives
1107            .local
1108            .to_dense(x.len())
1109            .expect("dense local derivatives");
1110        for (sample, (&plus_value, &minus_value)) in
1111            position_plus.y.iter().zip(&position_minus.y).enumerate()
1112        {
1113            let finite_difference = (plus_value - minus_value) / (2.0 * position_step);
1114            let analytical = dense[x.len() + sample];
1115            assert!(
1116                (analytical - finite_difference).abs() < 8.0e-6 * finite_difference.abs().max(1.0)
1117            );
1118        }
1119
1120        let amplitude_step = 1.0e-8;
1121        let amplitude_plus = calculate(position, amplitude + amplitude_step);
1122        let amplitude_minus = calculate(position, amplitude - amplitude_step);
1123        let global = baseline.derivatives.global.as_ref().expect("global");
1124        for (sample, (&plus_value, &minus_value)) in
1125            amplitude_plus.y.iter().zip(&amplitude_minus.y).enumerate()
1126        {
1127            let finite_difference = (plus_value - minus_value) / (2.0 * amplitude_step);
1128            let analytical = global.values[INSTRUMENT_PARAMETER_COUNT * x.len() + sample];
1129            assert!(
1130                (analytical - finite_difference).abs() < 8.0e-6 * finite_difference.abs().max(1.0)
1131            );
1132        }
1133    }
1134
1135    #[test]
1136    fn zero_fcj_geometry_exactly_matches_symmetric_contributions() {
1137        let x: Vec<f64> = (0..=2_000)
1138            .map(|index| 39.0 + f64::from(index) * 0.001)
1139            .collect();
1140        let positions = [39.8, 40.2];
1141        let intensities = [12.0, 7.0];
1142        let variance = [2.0e-5, 3.0e-5];
1143        let lorentzian = [1.0e-3, 2.0e-3];
1144        let multiplier = [0.8, 1.2];
1145        let zeros = [0.0, 0.0];
1146        let provider = [0.1, 0.2];
1147        let arrays = CwContributionArrays {
1148            gaussian_variance_deg2: &variance,
1149            lorentzian_fwhm_deg: &lorentzian,
1150            intensity_multiplier: &multiplier,
1151            d_gaussian_variance_d_position: &zeros,
1152            d_lorentzian_fwhm_d_position: &zeros,
1153            d_intensity_multiplier_d_position: &zeros,
1154            d_gaussian_variance_d_parameters: &zeros,
1155            d_lorentzian_fwhm_d_parameters: &zeros,
1156            d_intensity_multiplier_d_parameters: &provider,
1157        };
1158        let contributions = CwContributionsView::new(2, 1, arrays).expect("contributions");
1159        let grid = GridView::new(&x).expect("grid");
1160        let support = SupportPolicy::FwhmMultiple(20.0);
1161        let symmetric = accumulate_cw_contributions_batch(
1162            grid,
1163            &positions,
1164            &intensities,
1165            instrument(),
1166            contributions,
1167            support,
1168        )
1169        .expect("symmetric");
1170        let fcj = accumulate_cw_fcj_contributions_batch(
1171            grid,
1172            &positions,
1173            &intensities,
1174            instrument(),
1175            contributions,
1176            FcjGeometry {
1177                sample_over_radius: 0.0,
1178                detector_over_radius: 0.0,
1179            },
1180            support,
1181        )
1182        .expect("FCJ");
1183        assert_eq!(fcj.y, symmetric.y);
1184        assert_eq!(fcj.derivatives.local, symmetric.derivatives.local);
1185        let symmetric_global = symmetric.derivatives.global.expect("symmetric global");
1186        let fcj_global = fcj.derivatives.global.expect("FCJ global");
1187        assert_eq!(
1188            &fcj_global.values[..INSTRUMENT_PARAMETER_COUNT * x.len()],
1189            &symmetric_global.values[..INSTRUMENT_PARAMETER_COUNT * x.len()]
1190        );
1191        assert_eq!(
1192            &fcj_global.values[(INSTRUMENT_PARAMETER_COUNT + FCJ_PARAMETER_COUNT) * x.len()..],
1193            &symmetric_global.values[INSTRUMENT_PARAMETER_COUNT * x.len()..]
1194        );
1195    }
1196
1197    #[test]
1198    fn fcj_geometry_and_provider_derivatives_match_centered_differences() {
1199        let x: Vec<f64> = (0..=2_000)
1200            .map(|index| 49.5 + f64::from(index) * 0.000_5)
1201            .collect();
1202        let position = [50.0];
1203        let intensity = [8.0];
1204        let support = SupportPolicy::FwhmMultiple(100.0);
1205        let calculate = |sample: f64, detector: f64, amplitude: f64| {
1206            let variance = [amplitude * 0.25];
1207            let zeros = [0.0];
1208            let ones = [1.0];
1209            let d_variance = [0.25];
1210            let arrays = CwContributionArrays {
1211                gaussian_variance_deg2: &variance,
1212                lorentzian_fwhm_deg: &zeros,
1213                intensity_multiplier: &ones,
1214                d_gaussian_variance_d_position: &zeros,
1215                d_lorentzian_fwhm_d_position: &zeros,
1216                d_intensity_multiplier_d_position: &zeros,
1217                d_gaussian_variance_d_parameters: &d_variance,
1218                d_lorentzian_fwhm_d_parameters: &zeros,
1219                d_intensity_multiplier_d_parameters: &zeros,
1220            };
1221            accumulate_cw_fcj_contributions_batch(
1222                GridView::new(&x).expect("grid"),
1223                &position,
1224                &intensity,
1225                instrument(),
1226                CwContributionsView::new(1, 1, arrays).expect("contributions"),
1227                FcjGeometry {
1228                    sample_over_radius: sample,
1229                    detector_over_radius: detector,
1230                },
1231                support,
1232            )
1233            .expect("FCJ contributions")
1234        };
1235        let sample = 0.013;
1236        let detector = 0.009;
1237        let amplitude = 3.0e-4;
1238        let baseline = calculate(sample, detector, amplitude);
1239        let global = baseline.derivatives.global.as_ref().expect("global");
1240        for (parameter, step, plus, minus) in [
1241            (
1242                INSTRUMENT_PARAMETER_COUNT,
1243                1.0e-7,
1244                calculate(sample + 1.0e-7, detector, amplitude),
1245                calculate(sample - 1.0e-7, detector, amplitude),
1246            ),
1247            (
1248                INSTRUMENT_PARAMETER_COUNT + 1,
1249                1.0e-7,
1250                calculate(sample, detector + 1.0e-7, amplitude),
1251                calculate(sample, detector - 1.0e-7, amplitude),
1252            ),
1253            (
1254                INSTRUMENT_PARAMETER_COUNT + FCJ_PARAMETER_COUNT,
1255                1.0e-8,
1256                calculate(sample, detector, amplitude + 1.0e-8),
1257                calculate(sample, detector, amplitude - 1.0e-8),
1258            ),
1259        ] {
1260            for sample_index in 0..x.len() {
1261                let finite_difference =
1262                    (plus.y[sample_index] - minus.y[sample_index]) / (2.0 * step);
1263                let analytical = global.values[parameter * x.len() + sample_index];
1264                assert!(
1265                    (analytical - finite_difference).abs()
1266                        < 2.0e-4 * finite_difference.abs().max(1.0),
1267                    "parameter {parameter}, sample {sample_index}: {analytical} != {finite_difference}"
1268                );
1269            }
1270        }
1271    }
1272
1273    #[test]
1274    fn owned_contributions_validate_once_and_reborrow_without_changes() {
1275        let owned = OwnedCwContributions::new(
1276            2,
1277            1,
1278            OwnedCwContributionArrays {
1279                gaussian_variance_deg2: vec![0.0, 0.25],
1280                lorentzian_fwhm_deg: vec![0.1, 0.2],
1281                intensity_multiplier: vec![1.0, 0.5],
1282                d_gaussian_variance_d_position: vec![0.0, 0.0],
1283                d_lorentzian_fwhm_d_position: vec![0.0, 0.0],
1284                d_intensity_multiplier_d_position: vec![0.0, 0.0],
1285                d_gaussian_variance_d_parameters: vec![0.3, 0.4],
1286                d_lorentzian_fwhm_d_parameters: vec![0.0, 0.0],
1287                d_intensity_multiplier_d_parameters: vec![0.0, 0.0],
1288            },
1289        )
1290        .expect("owned contributions");
1291
1292        assert_eq!(owned.reflection_count(), 2);
1293        assert_eq!(owned.parameter_count(), 1);
1294        assert_eq!(owned.as_view().parameter_count(), 1);
1295        assert_eq!(owned.arrays().intensity_multiplier, [1.0, 0.5]);
1296    }
1297
1298    #[test]
1299    fn neutral_owned_contributions_have_valid_identity_values() {
1300        let owned = OwnedCwContributions::neutral(3);
1301        assert_eq!(owned.reflection_count(), 3);
1302        assert_eq!(owned.parameter_count(), 0);
1303        assert_eq!(owned.arrays().gaussian_variance_deg2, [0.0; 3]);
1304        assert_eq!(owned.arrays().intensity_multiplier, [1.0; 3]);
1305        assert_eq!(owned.as_view().parameter_count(), 0);
1306    }
1307
1308    #[test]
1309    fn owned_contributions_reject_invalid_arrays_before_storage() {
1310        assert!(matches!(
1311            OwnedCwContributions::new(
1312                1,
1313                0,
1314                OwnedCwContributionArrays {
1315                    gaussian_variance_deg2: vec![-1.0],
1316                    lorentzian_fwhm_deg: vec![0.0],
1317                    intensity_multiplier: vec![1.0],
1318                    d_gaussian_variance_d_position: vec![0.0],
1319                    d_lorentzian_fwhm_d_position: vec![0.0],
1320                    d_intensity_multiplier_d_position: vec![0.0],
1321                    ..OwnedCwContributionArrays::default()
1322                },
1323            ),
1324            Err(CwContributionsError::InvalidContribution {
1325                quantity: "gaussian_variance_deg2",
1326                ..
1327            })
1328        ));
1329    }
1330}