Skip to main content

phasesmith_engine/
structural_pattern.rs

1//! Fused built-in scattering, structural intensity, and CW profile composition.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use phasesmith_core::{
7    Accumulation, ConstantWavelengthInstrument, CwContributionsError, CwContributionsView, CwError,
8    FcjGeometry, GridView, ProfileError, SupportPolicy,
9    accumulate_cw_contributions_batch_with_context,
10    accumulate_cw_fcj_contributions_batch_with_context,
11};
12use phasesmith_crystallography::{
13    CellError, IntegratedIntensityCorrection, IntegratedIntensityCorrectionError,
14    IntegratedIntensityCorrectionModel, PreparedNeutronScattering, PreparedXrayScattering,
15    ScatteringBatch, ScatteringError, SpaceGroup, StructureFactorBatchError,
16    StructureFactorBatchView, StructureFactorValues, UnitCell,
17    calculate_structure_factor_dense_with_context,
18    calculate_structure_factor_intensity_vjp_with_context,
19    calculate_structure_factor_jvp_with_context, calculate_structure_factor_values_with_context,
20};
21use phasesmith_execution::ExecutionContext;
22
23const CELL_PARAMETER_COUNT: usize = 6;
24pub(crate) const CW_INSTRUMENT_PARAMETER_COUNT: usize = 5;
25const DEGREES_PER_RADIAN: f64 = 180.0 / std::f64::consts::PI;
26
27/// Monochromatic peak-position corrections evaluated with structural geometry.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct MonochromaticPositionCorrection {
30    /// Constant additive shift in degrees `2theta`.
31    pub zero_shift_deg: f64,
32    /// Optional Bragg--Brentano `(sample displacement, goniometer radius)` in mm.
33    pub bragg_brentano_mm: Option<(f64, f64)>,
34    /// Optional Debye--Scherrer `(X, Y, radius)` with displacements in micrometres
35    /// and the goniometer radius in millimetres.
36    pub debye_scherrer_micrometre: Option<(f64, f64, f64)>,
37}
38
39/// Reflection geometry needed by built-in sample-physics providers.
40#[derive(Clone, Debug, PartialEq)]
41pub struct MonochromaticReflectionGeometry {
42    /// Reflection d-spacings in ångströms.
43    pub d_spacing_angstrom: Vec<f64>,
44    /// Corrected reflection positions in degrees `2theta`.
45    pub two_theta_deg: Vec<f64>,
46}
47
48/// Calculate corrected monochromatic reflection positions without profiles.
49///
50/// # Errors
51///
52/// Returns [`StructuralPatternError`] for invalid cell, wavelength, position
53/// correction, or inaccessible reflections.
54pub fn calculate_monochromatic_reflection_geometry(
55    cell: UnitCell,
56    hkl: &[[i32; 3]],
57    instrument: ConstantWavelengthInstrument,
58    position_correction: MonochromaticPositionCorrection,
59) -> Result<MonochromaticReflectionGeometry, StructuralPatternError> {
60    instrument
61        .validate()
62        .map_err(StructuralPatternError::InvalidInstrument)?;
63    validate_position_correction(position_correction)?;
64    let geometry = cell
65        .geometry()
66        .map_err(StructureFactorBatchError::Cell)
67        .map_err(StructuralPatternError::StructureFactor)?;
68    let mut d_spacing_angstrom = Vec::with_capacity(hkl.len());
69    let mut two_theta_deg = Vec::with_capacity(hkl.len());
70    for &reflection in hkl {
71        let q_value = geometry.q_squared(reflection);
72        if !q_value.is_finite() || q_value <= 0.0 {
73            return Err(StructuralPatternError::ReflectionOutsideAngularDomain);
74        }
75        let root_q = q_value.sqrt();
76        let sin_theta = 0.5 * instrument.wavelength_angstrom * root_q;
77        if !(0.0..1.0).contains(&sin_theta) {
78            return Err(StructuralPatternError::ReflectionOutsideAngularDomain);
79        }
80        d_spacing_angstrom.push(root_q.recip());
81        two_theta_deg.push(
82            corrected_monochromatic_position(2.0 * sin_theta.asin(), position_correction)
83                .position_deg,
84        );
85    }
86    Ok(MonochromaticReflectionGeometry {
87        d_spacing_angstrom,
88        two_theta_deg,
89    })
90}
91
92/// Built-in native scattering model selected without a Python callback.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum BuiltInScatteringModel {
95    /// Non-resonant Waasmaier--Kirfel X-ray form factors.
96    XrayNonResonant,
97    /// Constant bound coherent nuclear-neutron scattering lengths.
98    NeutronNuclear,
99}
100
101/// Borrowed structure, reflection, profile, and sample-physics inputs.
102#[derive(Clone, Copy, Debug)]
103pub struct StructuralPatternInputView<'a> {
104    /// Sorted pattern grid in degrees `2theta`.
105    pub x_deg: &'a [f64],
106    /// Canonical Miller indices.
107    pub hkl: &'a [[i32; 3]],
108    /// Powder multiplicity for each reflection.
109    pub multiplicity: &'a [usize],
110    /// Asymmetric-unit fractional coordinates.
111    pub fractional_xyz: &'a [[f64; 3]],
112    /// Asymmetric-site occupancies.
113    pub occupancy: &'a [f64],
114    /// Asymmetric-site isotropic displacement in square ångströms.
115    pub u_iso_angstrom2: &'a [f64],
116    /// True for asymmetric sites described by fixed CIF U tensors.
117    pub anisotropic_mask: &'a [bool],
118    /// CIF U tensors in component order `11,22,33,23,13,12`.
119    pub u_aniso_cif_angstrom2: &'a [[f64; 6]],
120    /// Exact built-in table key for every asymmetric site.
121    pub scattering_species: &'a [&'a str],
122    /// Fixed real X-ray dispersion offset for every site, or empty when absent.
123    pub scattering_real_offset: &'a [f64],
124    /// Fixed imaginary X-ray dispersion offset for every site, or empty when absent.
125    pub scattering_imag_offset: &'a [f64],
126    /// Structural phase scale.
127    pub scale: f64,
128    /// Fixed symmetry-expansion deduplication tolerance.
129    pub coordinate_tolerance: f64,
130    /// Monochromatic CW instrument/profile parameters.
131    pub instrument: ConstantWavelengthInstrument,
132    /// Optional Finger--Cox--Jephcoat axial-divergence geometry.
133    pub axial_geometry: Option<FcjGeometry>,
134    /// Explicit zero/sample-displacement position correction.
135    pub position_correction: MonochromaticPositionCorrection,
136    /// Explicit integrated-intensity correction model.
137    pub correction_model: IntegratedIntensityCorrectionModel,
138    /// Built-in native scattering selection.
139    pub scattering_model: BuiltInScatteringModel,
140    /// Vectorized sample-physics contribution batch.
141    pub contributions: CwContributionsView<'a>,
142    /// Exact finite profile-support policy.
143    pub support: SupportPolicy,
144}
145
146/// Structural reflection intermediates and fused profile result.
147#[derive(Clone, Debug, PartialEq)]
148pub struct StructuralPatternResult {
149    /// Structure factors and integrated intensities before sample physics.
150    pub structure_factors: StructureFactorValues,
151    /// Reflection d-spacings in ångströms.
152    pub d_spacing_angstrom: Vec<f64>,
153    /// Monochromatic peak positions in degrees `2theta`.
154    pub two_theta_deg: Vec<f64>,
155    /// Support-limited profile values and local/global derivatives.
156    pub accumulation: Accumulation,
157}
158
159/// Fused values and one structural forward derivative product.
160#[derive(Clone, Debug, PartialEq)]
161pub struct StructuralPatternJvpResult {
162    /// Calculated structural pattern.
163    pub result: StructuralPatternResult,
164    /// Structural directional derivative of the pattern samples.
165    pub d_y: Vec<f64>,
166    /// Directional derivative of integrated reflection intensities.
167    pub d_integrated_intensity: Vec<f64>,
168    /// Directional derivative of reflection positions in degrees.
169    pub d_two_theta_deg: Vec<f64>,
170}
171
172/// Fused values and a reusable parameter-major structural pattern Jacobian.
173#[derive(Clone, Debug, PartialEq)]
174pub struct StructuralPatternDenseResult {
175    /// Calculated structural pattern.
176    pub result: StructuralPatternResult,
177    /// Pattern Jacobian with shape parameter count by sample count.
178    pub d_y: Vec<f64>,
179    /// Number of rows in the pattern Jacobian.
180    pub parameter_count: usize,
181}
182
183/// Fused values and one reverse product from pattern sample weights.
184#[derive(Clone, Debug, PartialEq)]
185pub struct StructuralPatternVjpResult {
186    /// Calculated structural pattern.
187    pub result: StructuralPatternResult,
188    /// Pattern-Jacobian transpose product in structural parameter order.
189    pub gradient: Vec<f64>,
190}
191
192/// Invalid fused structural-pattern request.
193#[derive(Debug)]
194pub enum StructuralPatternError {
195    /// The owned unit cell is invalid.
196    InvalidCell(CellError),
197    /// Reflection indices and multiplicities have different lengths.
198    ReflectionLengthMismatch,
199    /// Owned asymmetric-site arrays have different lengths.
200    SiteLengthMismatch,
201    /// Scattering species count does not match the asymmetric-site count.
202    SpeciesLengthMismatch,
203    /// Offset vectors are neither both empty nor matched to the asymmetric sites.
204    ScatteringOffsetLengthMismatch,
205    /// A fixed scattering offset is non-finite.
206    NonFiniteScatteringOffset,
207    /// Fixed dispersion offsets were supplied to a non-X-ray model.
208    UnsupportedScatteringOffset,
209    /// Built-in scattering preparation or evaluation failed.
210    Scattering(ScatteringError),
211    /// Integrated-intensity correction evaluation failed.
212    Correction(IntegratedIntensityCorrectionError),
213    /// General-symmetry structural intensity failed.
214    StructureFactor(StructureFactorBatchError),
215    /// Grid validation failed.
216    Profile(ProfileError),
217    /// CW/sample-physics accumulation failed.
218    Contributions(CwContributionsError),
219    /// A reflection is inaccessible for the monochromatic wavelength.
220    ReflectionOutsideAngularDomain,
221    /// The CW instrument model is invalid.
222    InvalidInstrument(CwError),
223    /// A position-correction parameter is invalid.
224    InvalidPositionCorrection,
225    /// Pattern reverse weights do not match the sample count.
226    PatternWeightLengthMismatch,
227    /// A pattern reverse weight is non-finite.
228    NonFinitePatternWeight,
229}
230
231impl Display for StructuralPatternError {
232    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
233        match self {
234            Self::InvalidCell(error) => Display::fmt(error, formatter),
235            Self::ReflectionLengthMismatch => {
236                formatter.write_str("hkl and multiplicity must have the same reflection count")
237            }
238            Self::SiteLengthMismatch => {
239                formatter.write_str("all structural site arrays must have the same site count")
240            }
241            Self::SpeciesLengthMismatch => {
242                formatter.write_str("scattering species must contain one key per asymmetric site")
243            }
244            Self::ScatteringOffsetLengthMismatch => formatter
245                .write_str("scattering offset vectors must both be empty or match the site count"),
246            Self::NonFiniteScatteringOffset => {
247                formatter.write_str("scattering offsets must be finite")
248            }
249            Self::UnsupportedScatteringOffset => formatter
250                .write_str("fixed scattering offsets are supported only for X-ray scattering"),
251            Self::Scattering(error) => Display::fmt(error, formatter),
252            Self::Correction(error) => Display::fmt(error, formatter),
253            Self::StructureFactor(error) => Display::fmt(error, formatter),
254            Self::Profile(error) => Display::fmt(error, formatter),
255            Self::Contributions(error) => Display::fmt(error, formatter),
256            Self::ReflectionOutsideAngularDomain => formatter.write_str(
257                "structural CW reflections must lie strictly within 0 < 2theta < 180 degrees",
258            ),
259            Self::InvalidInstrument(error) => Display::fmt(error, formatter),
260            Self::InvalidPositionCorrection => formatter.write_str(
261                "position corrections must be finite and goniometer radius must be positive",
262            ),
263            Self::PatternWeightLengthMismatch => {
264                formatter.write_str("pattern reverse weights must match the sample count")
265            }
266            Self::NonFinitePatternWeight => {
267                formatter.write_str("pattern reverse weights must be finite")
268            }
269        }
270    }
271}
272
273impl Error for StructuralPatternError {}
274
275struct PreparedNumerics {
276    scattering: ScatteringBatch,
277    correction: IntegratedIntensityCorrection,
278    d_spacing: Vec<f64>,
279    two_theta_deg: Vec<f64>,
280    d_two_theta_d_cell: Vec<[f64; CELL_PARAMETER_COUNT]>,
281    d_two_theta_d_wavelength: Vec<f64>,
282    d_two_theta_d_sample_displacement: Option<Vec<f64>>,
283    d_two_theta_d_displace_x: Option<Vec<f64>>,
284    d_two_theta_d_displace_y: Option<Vec<f64>>,
285}
286
287#[derive(Clone, Copy, Debug)]
288struct CorrectedPosition {
289    position_deg: f64,
290    d_position_d_base: f64,
291    d_position_d_sample_displacement: Option<f64>,
292    d_position_d_displace_x: Option<f64>,
293    d_position_d_displace_y: Option<f64>,
294}
295
296fn corrected_monochromatic_position(
297    base_position_radians: f64,
298    correction: MonochromaticPositionCorrection,
299) -> CorrectedPosition {
300    let mut result = CorrectedPosition {
301        position_deg: base_position_radians.to_degrees() + correction.zero_shift_deg,
302        d_position_d_base: 1.0,
303        d_position_d_sample_displacement: None,
304        d_position_d_displace_x: None,
305        d_position_d_displace_y: None,
306    };
307    if let Some((displacement, radius)) = correction.bragg_brentano_mm {
308        let theta = 0.5 * base_position_radians;
309        result.position_deg -= 2.0 * displacement / radius * theta.cos() * DEGREES_PER_RADIAN;
310        result.d_position_d_base += displacement / radius * theta.sin();
311        result.d_position_d_sample_displacement =
312            Some(-2.0 / radius * theta.cos() * DEGREES_PER_RADIAN);
313    }
314    if let Some((displace_x, displace_y, radius)) = correction.debye_scherrer_micrometre {
315        let (sin_position, cos_position) = base_position_radians.sin_cos();
316        let displacement_scale = 0.18 / (std::f64::consts::PI * radius);
317        result.position_deg -=
318            displacement_scale * (displace_x * cos_position + displace_y * sin_position);
319        result.d_position_d_base += displacement_scale.to_radians()
320            * (displace_x * sin_position - displace_y * cos_position);
321        result.d_position_d_displace_x = Some(-displacement_scale * cos_position);
322        result.d_position_d_displace_y = Some(-displacement_scale * sin_position);
323    }
324    result
325}
326
327fn validate_position_correction(
328    correction: MonochromaticPositionCorrection,
329) -> Result<(), StructuralPatternError> {
330    let invalid = !correction.zero_shift_deg.is_finite()
331        || (correction.bragg_brentano_mm.is_some()
332            && correction.debye_scherrer_micrometre.is_some())
333        || correction
334            .bragg_brentano_mm
335            .is_some_and(|(displacement, radius)| {
336                !displacement.is_finite() || !radius.is_finite() || radius <= 0.0
337            })
338        || correction
339            .debye_scherrer_micrometre
340            .is_some_and(|(displace_x, displace_y, radius)| {
341                !displace_x.is_finite()
342                    || !displace_y.is_finite()
343                    || !radius.is_finite()
344                    || radius <= 0.0
345            });
346    if invalid {
347        return Err(StructuralPatternError::InvalidPositionCorrection);
348    }
349    Ok(())
350}
351
352fn validate_scattering_offsets(
353    input: &StructuralPatternInputView<'_>,
354) -> Result<(), StructuralPatternError> {
355    if input.scattering_real_offset.is_empty() && input.scattering_imag_offset.is_empty() {
356        return Ok(());
357    }
358    if input.scattering_real_offset.len() != input.fractional_xyz.len()
359        || input.scattering_imag_offset.len() != input.fractional_xyz.len()
360    {
361        return Err(StructuralPatternError::ScatteringOffsetLengthMismatch);
362    }
363    if input
364        .scattering_real_offset
365        .iter()
366        .chain(input.scattering_imag_offset)
367        .any(|value| !value.is_finite())
368    {
369        return Err(StructuralPatternError::NonFiniteScatteringOffset);
370    }
371    if input.scattering_model == BuiltInScatteringModel::NeutronNuclear
372        && input
373            .scattering_real_offset
374            .iter()
375            .chain(input.scattering_imag_offset)
376            .any(|value| *value != 0.0)
377    {
378        return Err(StructuralPatternError::UnsupportedScatteringOffset);
379    }
380    Ok(())
381}
382
383fn apply_scattering_offsets(
384    scattering: &mut ScatteringBatch,
385    input: &StructuralPatternInputView<'_>,
386) {
387    if input.scattering_real_offset.is_empty() {
388        return;
389    }
390    for reflection in 0..scattering.reflection_count {
391        for site in 0..scattering.site_count {
392            let index = reflection * scattering.site_count + site;
393            scattering.real[index] += input.scattering_real_offset[site];
394            scattering.imag[index] += input.scattering_imag_offset[site];
395        }
396    }
397}
398
399impl PreparedNumerics {
400    fn structure_batch<'a>(
401        &'a self,
402        input: &StructuralPatternInputView<'a>,
403    ) -> StructureFactorBatchView<'a> {
404        StructureFactorBatchView {
405            hkl: input.hkl,
406            multiplicity: input.multiplicity,
407            fractional_xyz: input.fractional_xyz,
408            occupancy: input.occupancy,
409            u_iso_angstrom2: input.u_iso_angstrom2,
410            anisotropic_mask: input.anisotropic_mask,
411            u_aniso_cif_angstrom2: input.u_aniso_cif_angstrom2,
412            scattering_real: &self.scattering.real,
413            scattering_imag: &self.scattering.imag,
414            d_scattering_real_d_s: &self.scattering.d_real_d_s,
415            d_scattering_imag_d_s: &self.scattering.d_imag_d_s,
416            correction: &self.correction.values,
417            d_correction_d_q_squared: &self.correction.d_values_d_q_squared,
418            scale: input.scale,
419            coordinate_tolerance: input.coordinate_tolerance,
420        }
421    }
422}
423
424/// Calculate built-in scattering, structural intensities, and one CW profile.
425///
426/// # Errors
427///
428/// Returns [`StructuralPatternError`] for invalid structural, scattering,
429/// correction, instrument, contribution, grid, or support inputs.
430pub fn calculate_structural_pattern(
431    cell: UnitCell,
432    space_group: &SpaceGroup,
433    input: &StructuralPatternInputView<'_>,
434) -> Result<StructuralPatternResult, StructuralPatternError> {
435    calculate_structural_pattern_with_context(cell, space_group, input, &ExecutionContext::serial())
436}
437
438/// Calculate a structural pattern with an explicit bounded execution context.
439///
440/// # Errors
441///
442/// Returns [`StructuralPatternError`] for invalid inputs.
443pub fn calculate_structural_pattern_with_context(
444    cell: UnitCell,
445    space_group: &SpaceGroup,
446    input: &StructuralPatternInputView<'_>,
447    execution: &ExecutionContext,
448) -> Result<StructuralPatternResult, StructuralPatternError> {
449    let prepared = prepare(cell, input)?;
450    calculate_values(cell, space_group, input, &prepared, execution)
451}
452
453/// Calculate values and a reusable dense structural pattern linearization.
454///
455/// # Errors
456///
457/// Returns an error for invalid inputs or allocation overflow.
458pub fn calculate_structural_pattern_dense(
459    cell: UnitCell,
460    space_group: &SpaceGroup,
461    input: &StructuralPatternInputView<'_>,
462) -> Result<StructuralPatternDenseResult, StructuralPatternError> {
463    calculate_structural_pattern_dense_with_context(
464        cell,
465        space_group,
466        input,
467        &ExecutionContext::serial(),
468    )
469}
470
471/// Calculate a dense structural linearization with a bounded context.
472///
473/// # Errors
474///
475/// Returns an error for invalid inputs or allocation overflow.
476pub fn calculate_structural_pattern_dense_with_context(
477    cell: UnitCell,
478    space_group: &SpaceGroup,
479    input: &StructuralPatternInputView<'_>,
480    execution: &ExecutionContext,
481) -> Result<StructuralPatternDenseResult, StructuralPatternError> {
482    let prepared = prepare(cell, input)?;
483    let structural = calculate_structure_factor_dense_with_context(
484        cell,
485        space_group,
486        prepared.structure_batch(input),
487        execution,
488    )
489    .map_err(StructuralPatternError::StructureFactor)?;
490    let parameter_count = structural.layout.parameter_count();
491    let sample_count = input.x_deg.len();
492    let element_count =
493        parameter_count
494            .checked_mul(sample_count)
495            .ok_or(StructuralPatternError::Contributions(
496                CwContributionsError::AllocationOverflow,
497            ))?;
498    let mut accumulation = accumulate(
499        input,
500        &prepared.two_theta_deg,
501        &structural.values.intensity,
502        execution,
503    )?;
504    append_instrument_derivatives(&mut accumulation, &structural.values, input, &prepared)?;
505    let reflection_count = input.hkl.len();
506    let local = &accumulation.derivatives.local;
507    let d_y = if execution.threads() == 1 || parameter_count < 2 {
508        let mut values = zeroed_values(element_count)?;
509        for reflection in 0..reflection_count {
510            let begin = local.offsets[reflection];
511            let end = local.offsets[reflection + 1];
512            for active in begin..end {
513                let sample = local.starts[reflection] + active - begin;
514                let local_base = 2 * active;
515                for parameter in 0..parameter_count {
516                    let structural_index = parameter * reflection_count + reflection;
517                    let position_derivative = if parameter < CELL_PARAMETER_COUNT {
518                        prepared.d_two_theta_d_cell[reflection][parameter]
519                    } else {
520                        0.0
521                    };
522                    values[parameter * sample_count + sample] += local.values[local_base]
523                        * structural.d_intensity[structural_index]
524                        + local.values[local_base + 1] * position_derivative;
525                }
526            }
527        }
528        values
529    } else {
530        let rows = execution.map_ordered(parameter_count, 2, |parameter| {
531            let mut row = zeroed_values(sample_count)?;
532            for reflection in 0..reflection_count {
533                let begin = local.offsets[reflection];
534                let end = local.offsets[reflection + 1];
535                for active in begin..end {
536                    let sample = local.starts[reflection] + active - begin;
537                    let local_base = 2 * active;
538                    let structural_index = parameter * reflection_count + reflection;
539                    let position_derivative = if parameter < CELL_PARAMETER_COUNT {
540                        prepared.d_two_theta_d_cell[reflection][parameter]
541                    } else {
542                        0.0
543                    };
544                    row[sample] += local.values[local_base]
545                        * structural.d_intensity[structural_index]
546                        + local.values[local_base + 1] * position_derivative;
547                }
548            }
549            Ok::<_, StructuralPatternError>(row)
550        });
551        let mut values = Vec::new();
552        values.try_reserve_exact(element_count).map_err(|_| {
553            StructuralPatternError::Contributions(CwContributionsError::AllocationOverflow)
554        })?;
555        for row in rows {
556            values.extend(row?);
557        }
558        values
559    };
560    Ok(StructuralPatternDenseResult {
561        result: StructuralPatternResult {
562            structure_factors: structural.values,
563            d_spacing_angstrom: prepared.d_spacing,
564            two_theta_deg: prepared.two_theta_deg,
565            accumulation,
566        },
567        d_y,
568        parameter_count,
569    })
570}
571
572fn zeroed_values(count: usize) -> Result<Vec<f64>, StructuralPatternError> {
573    let mut values = Vec::new();
574    values.try_reserve_exact(count).map_err(|_| {
575        StructuralPatternError::Contributions(CwContributionsError::AllocationOverflow)
576    })?;
577    values.resize(count, 0.0);
578    Ok(values)
579}
580
581/// Calculate a full structural-pattern JVP without a dense pattern Jacobian.
582///
583/// # Errors
584///
585/// Returns [`StructuralPatternError`] for invalid inputs or structural tangent.
586pub fn calculate_structural_pattern_jvp(
587    cell: UnitCell,
588    space_group: &SpaceGroup,
589    input: &StructuralPatternInputView<'_>,
590    tangent: &[f64],
591) -> Result<StructuralPatternJvpResult, StructuralPatternError> {
592    calculate_structural_pattern_jvp_with_context(
593        cell,
594        space_group,
595        input,
596        tangent,
597        &ExecutionContext::serial(),
598    )
599}
600
601/// Calculate a structural JVP with a bounded execution context.
602///
603/// # Errors
604///
605/// Returns [`StructuralPatternError`] for invalid inputs or tangent shape.
606pub fn calculate_structural_pattern_jvp_with_context(
607    cell: UnitCell,
608    space_group: &SpaceGroup,
609    input: &StructuralPatternInputView<'_>,
610    tangent: &[f64],
611    execution: &ExecutionContext,
612) -> Result<StructuralPatternJvpResult, StructuralPatternError> {
613    let prepared = prepare(cell, input)?;
614    let structural = calculate_structure_factor_jvp_with_context(
615        cell,
616        space_group,
617        prepared.structure_batch(input),
618        tangent,
619        execution,
620    )
621    .map_err(StructuralPatternError::StructureFactor)?;
622    let d_two_theta_deg = prepared
623        .d_two_theta_d_cell
624        .iter()
625        .map(|derivatives| {
626            derivatives
627                .iter()
628                .zip(&tangent[..CELL_PARAMETER_COUNT])
629                .map(|(derivative, direction)| derivative * direction)
630                .sum::<f64>()
631        })
632        .collect::<Vec<_>>();
633    let mut accumulation = accumulate(
634        input,
635        &prepared.two_theta_deg,
636        &structural.values.intensity,
637        execution,
638    )?;
639    append_instrument_derivatives(&mut accumulation, &structural.values, input, &prepared)?;
640    let d_y = chain_pattern_jvp(&accumulation, &structural.d_intensity, &d_two_theta_deg);
641    Ok(StructuralPatternJvpResult {
642        result: StructuralPatternResult {
643            structure_factors: structural.values,
644            d_spacing_angstrom: prepared.d_spacing,
645            two_theta_deg: prepared.two_theta_deg,
646            accumulation,
647        },
648        d_y,
649        d_integrated_intensity: structural.d_intensity,
650        d_two_theta_deg,
651    })
652}
653
654/// Calculate a full structural-pattern transpose product from sample weights.
655///
656/// # Errors
657///
658/// Returns [`StructuralPatternError`] for invalid inputs or sample weights.
659pub fn calculate_structural_pattern_vjp(
660    cell: UnitCell,
661    space_group: &SpaceGroup,
662    input: &StructuralPatternInputView<'_>,
663    sample_weights: &[f64],
664) -> Result<StructuralPatternVjpResult, StructuralPatternError> {
665    calculate_structural_pattern_vjp_with_context(
666        cell,
667        space_group,
668        input,
669        sample_weights,
670        &ExecutionContext::serial(),
671    )
672}
673
674/// Calculate a structural transpose product with a bounded context.
675///
676/// # Errors
677///
678/// Returns [`StructuralPatternError`] for invalid inputs or sample weights.
679pub fn calculate_structural_pattern_vjp_with_context(
680    cell: UnitCell,
681    space_group: &SpaceGroup,
682    input: &StructuralPatternInputView<'_>,
683    sample_weights: &[f64],
684    execution: &ExecutionContext,
685) -> Result<StructuralPatternVjpResult, StructuralPatternError> {
686    if sample_weights.len() != input.x_deg.len() {
687        return Err(StructuralPatternError::PatternWeightLengthMismatch);
688    }
689    if sample_weights.iter().any(|value| !value.is_finite()) {
690        return Err(StructuralPatternError::NonFinitePatternWeight);
691    }
692    let prepared = prepare(cell, input)?;
693    let values = calculate_structure_factor_values_with_context(
694        cell,
695        space_group,
696        prepared.structure_batch(input),
697        execution,
698    )
699    .map_err(StructuralPatternError::StructureFactor)?;
700    let mut accumulation =
701        accumulate(input, &prepared.two_theta_deg, &values.intensity, execution)?;
702    append_instrument_derivatives(&mut accumulation, &values, input, &prepared)?;
703    let (intensity_weights, position_weights) =
704        local_transpose_weights(&accumulation, sample_weights);
705    let mut structural = calculate_structure_factor_intensity_vjp_with_context(
706        cell,
707        space_group,
708        prepared.structure_batch(input),
709        &intensity_weights,
710        execution,
711    )
712    .map_err(StructuralPatternError::StructureFactor)?;
713    for (reflection, weight) in position_weights.into_iter().enumerate() {
714        for parameter in 0..CELL_PARAMETER_COUNT {
715            structural.gradient[parameter] +=
716                weight * prepared.d_two_theta_d_cell[reflection][parameter];
717        }
718    }
719    Ok(StructuralPatternVjpResult {
720        result: StructuralPatternResult {
721            structure_factors: values,
722            d_spacing_angstrom: prepared.d_spacing,
723            two_theta_deg: prepared.two_theta_deg,
724            accumulation,
725        },
726        gradient: structural.gradient,
727    })
728}
729
730fn prepare(
731    cell: UnitCell,
732    input: &StructuralPatternInputView<'_>,
733) -> Result<PreparedNumerics, StructuralPatternError> {
734    if input.scattering_species.len() != input.fractional_xyz.len() {
735        return Err(StructuralPatternError::SpeciesLengthMismatch);
736    }
737    validate_scattering_offsets(input)?;
738    input
739        .instrument
740        .validate()
741        .map_err(StructuralPatternError::InvalidInstrument)?;
742    validate_position_correction(input.position_correction)?;
743    let geometry = cell
744        .geometry()
745        .map_err(StructureFactorBatchError::Cell)
746        .map_err(StructuralPatternError::StructureFactor)?;
747    let mut q_squared = Vec::with_capacity(input.hkl.len());
748    let mut d_spacing = Vec::with_capacity(input.hkl.len());
749    let mut two_theta_deg = Vec::with_capacity(input.hkl.len());
750    let mut d_two_theta_d_cell = Vec::with_capacity(input.hkl.len());
751    let mut d_two_theta_d_wavelength = Vec::with_capacity(input.hkl.len());
752    let mut d_two_theta_d_sample_displacement = input
753        .position_correction
754        .bragg_brentano_mm
755        .map(|_| Vec::with_capacity(input.hkl.len()));
756    let mut d_two_theta_d_displace_x = input
757        .position_correction
758        .debye_scherrer_micrometre
759        .map(|_| Vec::with_capacity(input.hkl.len()));
760    let mut d_two_theta_d_displace_y = input
761        .position_correction
762        .debye_scherrer_micrometre
763        .map(|_| Vec::with_capacity(input.hkl.len()));
764    for &hkl in input.hkl {
765        let (q_value, d_q) = geometry.q_squared_and_derivatives(hkl);
766        if !q_value.is_finite() || q_value <= 0.0 {
767            return Err(StructuralPatternError::ReflectionOutsideAngularDomain);
768        }
769        let root_q = q_value.sqrt();
770        let sin_theta = 0.5 * input.instrument.wavelength_angstrom * root_q;
771        if !(0.0..1.0).contains(&sin_theta) {
772            return Err(StructuralPatternError::ReflectionOutsideAngularDomain);
773        }
774        let theta = sin_theta.asin();
775        let corrected = corrected_monochromatic_position(2.0 * theta, input.position_correction);
776        let d_position_factor =
777            corrected.d_position_d_base * input.instrument.wavelength_angstrom * DEGREES_PER_RADIAN
778                / (2.0 * root_q * theta.cos());
779        let d_position_d_wavelength =
780            corrected.d_position_d_base * DEGREES_PER_RADIAN * root_q / theta.cos();
781        q_squared.push(q_value);
782        d_spacing.push(root_q.recip());
783        two_theta_deg.push(corrected.position_deg);
784        d_two_theta_d_cell.push(d_q.map(|derivative| d_position_factor * derivative));
785        d_two_theta_d_wavelength.push(d_position_d_wavelength);
786        if let (Some(values), Some(derivative)) = (
787            d_two_theta_d_sample_displacement.as_mut(),
788            corrected.d_position_d_sample_displacement,
789        ) {
790            values.push(derivative);
791        }
792        if let (Some(values), Some(derivative)) = (
793            d_two_theta_d_displace_x.as_mut(),
794            corrected.d_position_d_displace_x,
795        ) {
796            values.push(derivative);
797        }
798        if let (Some(values), Some(derivative)) = (
799            d_two_theta_d_displace_y.as_mut(),
800            corrected.d_position_d_displace_y,
801        ) {
802            values.push(derivative);
803        }
804    }
805    let s: Vec<f64> = q_squared.iter().map(|value| 0.5 * value.sqrt()).collect();
806    let mut scattering = match input.scattering_model {
807        BuiltInScatteringModel::XrayNonResonant => {
808            PreparedXrayScattering::new(input.scattering_species.iter().copied())
809                .and_then(|model| model.evaluate(&s))
810        }
811        BuiltInScatteringModel::NeutronNuclear => {
812            PreparedNeutronScattering::new(input.scattering_species.iter().copied())
813                .and_then(|model| model.evaluate(&s))
814        }
815    }
816    .map_err(StructuralPatternError::Scattering)?;
817    apply_scattering_offsets(&mut scattering, input);
818    let correction = input
819        .correction_model
820        .evaluate(&q_squared)
821        .map_err(StructuralPatternError::Correction)?;
822    Ok(PreparedNumerics {
823        scattering,
824        correction,
825        d_spacing,
826        two_theta_deg,
827        d_two_theta_d_cell,
828        d_two_theta_d_wavelength,
829        d_two_theta_d_sample_displacement,
830        d_two_theta_d_displace_x,
831        d_two_theta_d_displace_y,
832    })
833}
834
835fn calculate_values(
836    cell: UnitCell,
837    space_group: &SpaceGroup,
838    input: &StructuralPatternInputView<'_>,
839    prepared: &PreparedNumerics,
840    execution: &ExecutionContext,
841) -> Result<StructuralPatternResult, StructuralPatternError> {
842    let structure_factors = calculate_structure_factor_values_with_context(
843        cell,
844        space_group,
845        prepared.structure_batch(input),
846        execution,
847    )
848    .map_err(StructuralPatternError::StructureFactor)?;
849    let mut accumulation = accumulate(
850        input,
851        &prepared.two_theta_deg,
852        &structure_factors.intensity,
853        execution,
854    )?;
855    append_instrument_derivatives(&mut accumulation, &structure_factors, input, prepared)?;
856    Ok(StructuralPatternResult {
857        structure_factors,
858        d_spacing_angstrom: prepared.d_spacing.clone(),
859        two_theta_deg: prepared.two_theta_deg.clone(),
860        accumulation,
861    })
862}
863
864fn append_instrument_derivatives(
865    accumulation: &mut Accumulation,
866    structure_factors: &StructureFactorValues,
867    input: &StructuralPatternInputView<'_>,
868    prepared: &PreparedNumerics,
869) -> Result<(), StructuralPatternError> {
870    let sample_count = accumulation.sample_count;
871    let extra_count = 2
872        + usize::from(prepared.d_two_theta_d_sample_displacement.is_some())
873        + usize::from(prepared.d_two_theta_d_displace_x.is_some())
874        + usize::from(prepared.d_two_theta_d_displace_y.is_some());
875    let global = accumulation
876        .derivatives
877        .global
878        .as_mut()
879        .expect("CW contribution accumulation always has global derivatives");
880    let old_values = std::mem::take(&mut global.values);
881    let mut combined = Vec::new();
882    combined
883        .try_reserve(old_values.len() + extra_count * sample_count)
884        .map_err(|_| {
885            StructuralPatternError::Contributions(CwContributionsError::AllocationOverflow)
886        })?;
887    let mut wavelength = vec![0.0; sample_count];
888    let mut zero_shift = vec![0.0; sample_count];
889    let mut sample_displacement = prepared
890        .d_two_theta_d_sample_displacement
891        .as_ref()
892        .map(|_| vec![0.0; sample_count]);
893    let mut displace_x = prepared
894        .d_two_theta_d_displace_x
895        .as_ref()
896        .map(|_| vec![0.0; sample_count]);
897    let mut displace_y = prepared
898        .d_two_theta_d_displace_y
899        .as_ref()
900        .map(|_| vec![0.0; sample_count]);
901    let local = &accumulation.derivatives.local;
902    for reflection in 0..local.peak_count() {
903        #[allow(clippy::cast_precision_loss)]
904        let multiplicity = input.multiplicity[reflection] as f64;
905        let d_intensity_d_wavelength = input.scale
906            * multiplicity
907            * structure_factors.f_squared[reflection]
908            * prepared.correction.d_values_d_wavelength[reflection];
909        let begin = local.offsets[reflection];
910        let end = local.offsets[reflection + 1];
911        for active in begin..end {
912            let sample = local.starts[reflection] + active - begin;
913            let base = 2 * active;
914            let d_intensity = local.values[base];
915            let d_position = local.values[base + 1];
916            wavelength[sample] += d_intensity * d_intensity_d_wavelength
917                + d_position * prepared.d_two_theta_d_wavelength[reflection];
918            zero_shift[sample] += d_position;
919            if let (Some(values), Some(derivatives)) = (
920                sample_displacement.as_mut(),
921                prepared.d_two_theta_d_sample_displacement.as_ref(),
922            ) {
923                values[sample] += d_position * derivatives[reflection];
924            }
925            if let (Some(values), Some(derivatives)) = (
926                displace_x.as_mut(),
927                prepared.d_two_theta_d_displace_x.as_ref(),
928            ) {
929                values[sample] += d_position * derivatives[reflection];
930            }
931            if let (Some(values), Some(derivatives)) = (
932                displace_y.as_mut(),
933                prepared.d_two_theta_d_displace_y.as_ref(),
934            ) {
935                values[sample] += d_position * derivatives[reflection];
936            }
937        }
938    }
939    let instrument_end = CW_INSTRUMENT_PARAMETER_COUNT * sample_count;
940    combined.extend_from_slice(&old_values[..instrument_end]);
941    combined.extend(wavelength);
942    combined.extend(zero_shift);
943    if let Some(values) = sample_displacement {
944        combined.extend(values);
945    }
946    if let Some(values) = displace_x {
947        combined.extend(values);
948    }
949    if let Some(values) = displace_y {
950        combined.extend(values);
951    }
952    combined.extend_from_slice(&old_values[instrument_end..]);
953    global.values = combined;
954    global.parameter_count += extra_count;
955    Ok(())
956}
957
958fn accumulate(
959    input: &StructuralPatternInputView<'_>,
960    two_theta_deg: &[f64],
961    intensities: &[f64],
962    execution: &ExecutionContext,
963) -> Result<Accumulation, StructuralPatternError> {
964    let grid = GridView::new(input.x_deg).map_err(StructuralPatternError::Profile)?;
965    let result = match input.axial_geometry {
966        Some(geometry) => accumulate_cw_fcj_contributions_batch_with_context(
967            grid,
968            two_theta_deg,
969            intensities,
970            input.instrument,
971            input.contributions,
972            geometry,
973            input.support,
974            execution,
975        ),
976        None => accumulate_cw_contributions_batch_with_context(
977            grid,
978            two_theta_deg,
979            intensities,
980            input.instrument,
981            input.contributions,
982            input.support,
983            execution,
984        ),
985    };
986    result.map_err(StructuralPatternError::Contributions)
987}
988
989fn chain_pattern_jvp(
990    accumulation: &Accumulation,
991    d_intensity: &[f64],
992    d_position: &[f64],
993) -> Vec<f64> {
994    let local = &accumulation.derivatives.local;
995    let mut result = vec![0.0; accumulation.sample_count];
996    for reflection in 0..local.peak_count() {
997        let begin = local.offsets[reflection];
998        let end = local.offsets[reflection + 1];
999        for active in begin..end {
1000            let sample = local.starts[reflection] + active - begin;
1001            let base = 2 * active;
1002            result[sample] += local.values[base] * d_intensity[reflection]
1003                + local.values[base + 1] * d_position[reflection];
1004        }
1005    }
1006    result
1007}
1008
1009fn local_transpose_weights(
1010    accumulation: &Accumulation,
1011    sample_weights: &[f64],
1012) -> (Vec<f64>, Vec<f64>) {
1013    let local = &accumulation.derivatives.local;
1014    let mut intensity = vec![0.0; local.peak_count()];
1015    let mut position = vec![0.0; local.peak_count()];
1016    for reflection in 0..local.peak_count() {
1017        let begin = local.offsets[reflection];
1018        let end = local.offsets[reflection + 1];
1019        for active in begin..end {
1020            let sample = local.starts[reflection] + active - begin;
1021            let base = 2 * active;
1022            intensity[reflection] += local.values[base] * sample_weights[sample];
1023            position[reflection] += local.values[base + 1] * sample_weights[sample];
1024        }
1025    }
1026    (intensity, position)
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032    use phasesmith_core::CwContributionArrays;
1033    use phasesmith_crystallography::{P1ParameterLayout, Rational, SymmetryOperation};
1034
1035    fn cell() -> UnitCell {
1036        UnitCell {
1037            a_angstrom: 4.7,
1038            b_angstrom: 5.1,
1039            c_angstrom: 6.2,
1040            alpha_deg: 82.0,
1041            beta_deg: 87.0,
1042            gamma_deg: 74.0,
1043        }
1044    }
1045
1046    fn group() -> SpaceGroup {
1047        SpaceGroup::new(vec![
1048            SymmetryOperation::identity(),
1049            SymmetryOperation::new([[-1, 0, 0], [0, -1, 0], [0, 0, -1]], [Rational::zero(); 3])
1050                .expect("inversion"),
1051        ])
1052        .expect("P-1")
1053    }
1054
1055    fn instrument() -> ConstantWavelengthInstrument {
1056        ConstantWavelengthInstrument {
1057            wavelength_angstrom: 1.5406,
1058            u_deg2: 2.0e-4,
1059            v_deg2: -1.0e-4,
1060            w_deg2: 1.2e-4,
1061            x_deg: 1.5e-3,
1062            y_deg: 3.0e-3,
1063        }
1064    }
1065
1066    #[allow(clippy::too_many_arguments)]
1067    fn calculate_case(
1068        selected_cell: UnitCell,
1069        x: &[f64],
1070        hkl: &[[i32; 3]],
1071        multiplicity: &[usize],
1072        xyz: &[[f64; 3]],
1073        occupancy: &[f64],
1074        u_iso: &[f64],
1075        scale: f64,
1076        multiplier: &[f64],
1077    ) -> StructuralPatternResult {
1078        let zeros = vec![0.0; hkl.len()];
1079        let contributions = CwContributionsView::new(
1080            hkl.len(),
1081            0,
1082            CwContributionArrays {
1083                gaussian_variance_deg2: &zeros,
1084                lorentzian_fwhm_deg: &zeros,
1085                intensity_multiplier: multiplier,
1086                d_gaussian_variance_d_position: &zeros,
1087                d_lorentzian_fwhm_d_position: &zeros,
1088                d_intensity_multiplier_d_position: &zeros,
1089                d_gaussian_variance_d_parameters: &[],
1090                d_lorentzian_fwhm_d_parameters: &[],
1091                d_intensity_multiplier_d_parameters: &[],
1092            },
1093        )
1094        .expect("contributions");
1095        calculate_structural_pattern(
1096            selected_cell,
1097            &group(),
1098            &StructuralPatternInputView {
1099                x_deg: x,
1100                hkl,
1101                multiplicity,
1102                fractional_xyz: xyz,
1103                occupancy,
1104                u_iso_angstrom2: u_iso,
1105                anisotropic_mask: &[false, false],
1106                u_aniso_cif_angstrom2: &[[0.0; 6]; 2],
1107                scattering_species: &["Si", "O"],
1108                scattering_real_offset: &[],
1109                scattering_imag_offset: &[],
1110                scale,
1111                coordinate_tolerance: 1.0e-10,
1112                instrument: instrument(),
1113                axial_geometry: None,
1114                position_correction: MonochromaticPositionCorrection {
1115                    zero_shift_deg: 0.0,
1116                    bragg_brentano_mm: None,
1117                    debye_scherrer_micrometre: None,
1118                },
1119                correction_model: IntegratedIntensityCorrectionModel::Neutral,
1120                scattering_model: BuiltInScatteringModel::XrayNonResonant,
1121                contributions,
1122                support: SupportPolicy::FwhmMultiple(20.0),
1123            },
1124        )
1125        .expect("structural pattern")
1126    }
1127
1128    #[test]
1129    fn fused_values_apply_sample_intensity_multiplier_exactly_once() {
1130        let x: Vec<f64> = (0..9_001)
1131            .map(|index| 10.0 + f64::from(index) * 0.01)
1132            .collect();
1133        let hkl = [[1, 0, 1], [2, 1, 1], [1, 2, 3]];
1134        let multiplicity = [2, 4, 2];
1135        let xyz = [[0.17, 0.23, 0.31], [0.37, 0.11, 0.19]];
1136        let occupancy = [0.82, 0.55];
1137        let u_iso = [0.012, 0.018];
1138        let neutral = calculate_case(
1139            cell(),
1140            &x,
1141            &hkl,
1142            &multiplicity,
1143            &xyz,
1144            &occupancy,
1145            &u_iso,
1146            1.4,
1147            &[1.0; 3],
1148        );
1149        let doubled = calculate_case(
1150            cell(),
1151            &x,
1152            &hkl,
1153            &multiplicity,
1154            &xyz,
1155            &occupancy,
1156            &u_iso,
1157            1.4,
1158            &[2.0; 3],
1159        );
1160        assert_eq!(
1161            neutral.structure_factors.intensity,
1162            doubled.structure_factors.intensity
1163        );
1164        for (left, right) in neutral.accumulation.y.iter().zip(&doubled.accumulation.y) {
1165            assert!((2.0 * left - right).abs() < 2.0e-15 * right.abs().max(1.0));
1166        }
1167    }
1168
1169    #[test]
1170    fn reflection_geometry_helper_matches_the_fused_structural_positions() {
1171        let x = (0..9_001)
1172            .map(|index| 10.0 + f64::from(index) * 0.01)
1173            .collect::<Vec<_>>();
1174        let hkl = [[1, 0, 1], [2, 1, 1], [1, 2, 3]];
1175        let correction = MonochromaticPositionCorrection {
1176            zero_shift_deg: 0.0,
1177            bragg_brentano_mm: None,
1178            debye_scherrer_micrometre: None,
1179        };
1180        let fused = calculate_case(
1181            cell(),
1182            &x,
1183            &hkl,
1184            &[2, 4, 2],
1185            &[[0.17, 0.23, 0.31], [0.37, 0.11, 0.19]],
1186            &[0.82, 0.55],
1187            &[0.012, 0.018],
1188            1.4,
1189            &[1.0; 3],
1190        );
1191        let geometry =
1192            calculate_monochromatic_reflection_geometry(cell(), &hkl, instrument(), correction)
1193                .unwrap();
1194        assert_eq!(geometry.d_spacing_angstrom, fused.d_spacing_angstrom);
1195        assert_eq!(geometry.two_theta_deg, fused.two_theta_deg);
1196    }
1197
1198    #[test]
1199    #[allow(clippy::too_many_lines)]
1200    fn fused_jvp_vjp_match_pattern_finite_differences_and_adjoint_identity() {
1201        let x: Vec<f64> = (0..9_001)
1202            .map(|index| 10.0 + f64::from(index) * 0.01)
1203            .collect();
1204        let hkl = [[1, 0, 1], [2, 1, 1], [1, 2, 3]];
1205        let multiplicity = [2, 4, 2];
1206        let xyz = [[0.17, 0.23, 0.31], [0.37, 0.11, 0.19]];
1207        let occupancy = [0.82, 0.55];
1208        let u_iso = [0.012, 0.018];
1209        let scale = 1.4;
1210        let zeros = [0.0; 3];
1211        let ones = [1.0; 3];
1212        let contributions = CwContributionsView::new(
1213            hkl.len(),
1214            0,
1215            CwContributionArrays {
1216                gaussian_variance_deg2: &zeros,
1217                lorentzian_fwhm_deg: &zeros,
1218                intensity_multiplier: &ones,
1219                d_gaussian_variance_d_position: &zeros,
1220                d_lorentzian_fwhm_d_position: &zeros,
1221                d_intensity_multiplier_d_position: &zeros,
1222                d_gaussian_variance_d_parameters: &[],
1223                d_lorentzian_fwhm_d_parameters: &[],
1224                d_intensity_multiplier_d_parameters: &[],
1225            },
1226        )
1227        .expect("contributions");
1228        let input = StructuralPatternInputView {
1229            x_deg: &x,
1230            hkl: &hkl,
1231            multiplicity: &multiplicity,
1232            fractional_xyz: &xyz,
1233            occupancy: &occupancy,
1234            u_iso_angstrom2: &u_iso,
1235            anisotropic_mask: &[false, false],
1236            u_aniso_cif_angstrom2: &[[0.0; 6]; 2],
1237            scattering_species: &["Si", "O"],
1238            scattering_real_offset: &[],
1239            scattering_imag_offset: &[],
1240            scale,
1241            coordinate_tolerance: 1.0e-10,
1242            instrument: instrument(),
1243            axial_geometry: None,
1244            position_correction: MonochromaticPositionCorrection {
1245                zero_shift_deg: 0.0,
1246                bragg_brentano_mm: None,
1247                debye_scherrer_micrometre: None,
1248            },
1249            correction_model: IntegratedIntensityCorrectionModel::Neutral,
1250            scattering_model: BuiltInScatteringModel::XrayNonResonant,
1251            contributions,
1252            support: SupportPolicy::FwhmMultiple(20.0),
1253        };
1254        let layout = P1ParameterLayout { site_count: 2 };
1255        let tangent: Vec<f64> = (0..layout.parameter_count())
1256            .map(|index| f64::from(u32::try_from(index + 1).expect("small index")) * 2.0e-5)
1257            .collect();
1258        let jvp = calculate_structural_pattern_jvp(cell(), &group(), &input, &tangent)
1259            .expect("structural JVP");
1260        let dense = calculate_structural_pattern_dense(cell(), &group(), &input)
1261            .expect("structural dense linearization");
1262        assert_eq!(dense.parameter_count, tangent.len());
1263        for sample in 0..x.len() {
1264            let product = tangent
1265                .iter()
1266                .enumerate()
1267                .map(|(parameter, direction)| direction * dense.d_y[parameter * x.len() + sample])
1268                .sum::<f64>();
1269            assert!((product - jvp.d_y[sample]).abs() < 2.0e-11 * product.abs().max(1.0));
1270        }
1271        let step = 1.0e-5;
1272        let mut plus_cell = cell();
1273        let mut minus_cell = cell();
1274        for (parameter, direction) in tangent
1275            .iter()
1276            .copied()
1277            .take(CELL_PARAMETER_COUNT)
1278            .enumerate()
1279        {
1280            perturb_cell(&mut plus_cell, parameter, step * direction);
1281            perturb_cell(&mut minus_cell, parameter, -step * direction);
1282        }
1283        let mut plus_xyz = xyz;
1284        let mut minus_xyz = xyz;
1285        for site in 0..2 {
1286            for component in 0..3 {
1287                let direction = tangent[layout.coordinate(site, component)];
1288                plus_xyz[site][component] += step * direction;
1289                minus_xyz[site][component] -= step * direction;
1290            }
1291        }
1292        let mut plus_occupancy = occupancy;
1293        let mut minus_occupancy = occupancy;
1294        let mut plus_u = u_iso;
1295        let mut minus_u = u_iso;
1296        for site in 0..2 {
1297            plus_occupancy[site] += step * tangent[layout.occupancy(site)];
1298            minus_occupancy[site] -= step * tangent[layout.occupancy(site)];
1299            plus_u[site] += step * tangent[layout.u_iso(site)];
1300            minus_u[site] -= step * tangent[layout.u_iso(site)];
1301        }
1302        let plus = calculate_case(
1303            plus_cell,
1304            &x,
1305            &hkl,
1306            &multiplicity,
1307            &plus_xyz,
1308            &plus_occupancy,
1309            &plus_u,
1310            scale + step * tangent[layout.scale()],
1311            &ones,
1312        );
1313        let minus = calculate_case(
1314            minus_cell,
1315            &x,
1316            &hkl,
1317            &multiplicity,
1318            &minus_xyz,
1319            &minus_occupancy,
1320            &minus_u,
1321            scale - step * tangent[layout.scale()],
1322            &ones,
1323        );
1324        for ((actual, plus_value), minus_value) in jvp
1325            .d_y
1326            .iter()
1327            .zip(&plus.accumulation.y)
1328            .zip(&minus.accumulation.y)
1329        {
1330            let finite_difference = (plus_value - minus_value) / (2.0 * step);
1331            assert!((actual - finite_difference).abs() < 3.0e-5 * finite_difference.abs().max(1.0));
1332        }
1333        let sample_weights: Vec<f64> = x.iter().map(|value| (0.17 * value).sin()).collect();
1334        let vjp = calculate_structural_pattern_vjp(cell(), &group(), &input, &sample_weights)
1335            .expect("structural VJP");
1336        let forward = jvp
1337            .d_y
1338            .iter()
1339            .zip(&sample_weights)
1340            .map(|(derivative, weight)| derivative * weight)
1341            .sum::<f64>();
1342        let reverse = tangent
1343            .iter()
1344            .zip(&vjp.gradient)
1345            .map(|(direction, gradient)| direction * gradient)
1346            .sum::<f64>();
1347        assert!((forward - reverse).abs() < 2.0e-10 * forward.abs().max(1.0));
1348        for (parameter, actual) in vjp.gradient.iter().copied().enumerate() {
1349            let expected = dense.d_y[parameter * x.len()..(parameter + 1) * x.len()]
1350                .iter()
1351                .zip(&sample_weights)
1352                .map(|(derivative, weight)| derivative * weight)
1353                .sum::<f64>();
1354            assert!((actual - expected).abs() < 2.0e-10 * expected.abs().max(1.0));
1355        }
1356    }
1357
1358    fn perturb_cell(cell: &mut UnitCell, parameter: usize, change: f64) {
1359        let value = match parameter {
1360            0 => &mut cell.a_angstrom,
1361            1 => &mut cell.b_angstrom,
1362            2 => &mut cell.c_angstrom,
1363            3 => &mut cell.alpha_deg,
1364            4 => &mut cell.beta_deg,
1365            5 => &mut cell.gamma_deg,
1366            _ => panic!("invalid cell parameter"),
1367        };
1368        *value += change;
1369    }
1370}