Skip to main content

phasesmith_crystallography/
p1.rs

1//! P1 structure factors and analytical derivative products.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::cell::{CELL_PARAMETER_COUNT, CellError, CellGeometry, UnitCell};
7
8const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
9const TWO_PI_SQUARED: f64 = 2.0 * std::f64::consts::PI * std::f64::consts::PI;
10
11/// Borrowed reflection/site arrays for one P1 calculation.
12#[derive(Clone, Copy, Debug)]
13pub struct P1BatchView<'a> {
14    /// Miller indices, one row per reflection.
15    pub hkl: &'a [[i32; 3]],
16    /// Fractional coordinates, one row per atom site.
17    pub fractional_xyz: &'a [[f64; 3]],
18    /// Fractional site occupancies.
19    pub occupancy: &'a [f64],
20    /// Isotropic displacement values in square ångströms.
21    pub u_iso_angstrom2: &'a [f64],
22    /// Reflection-major real scattering amplitudes, length `R * S`.
23    pub scattering_real: &'a [f64],
24    /// Reflection-major imaginary scattering amplitudes, length `R * S`.
25    pub scattering_imag: &'a [f64],
26    /// Non-negative phase scale applied to `|F|^2`.
27    pub scale: f64,
28}
29
30/// Stable native parameter layout for a P1 batch.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct P1ParameterLayout {
33    /// Number of atom sites.
34    pub site_count: usize,
35}
36
37impl P1ParameterLayout {
38    /// Total parameter count: six cell, three coordinates per site, one
39    /// occupancy per site, one `U_iso` per site, and one scale.
40    #[must_use]
41    pub const fn parameter_count(self) -> usize {
42        CELL_PARAMETER_COUNT + 5 * self.site_count + 1
43    }
44
45    /// Index of fractional coordinate `component` for `site`.
46    #[must_use]
47    pub const fn coordinate(self, site: usize, component: usize) -> usize {
48        CELL_PARAMETER_COUNT + 3 * site + component
49    }
50
51    /// Index of occupancy for `site`.
52    #[must_use]
53    pub const fn occupancy(self, site: usize) -> usize {
54        CELL_PARAMETER_COUNT + 3 * self.site_count + site
55    }
56
57    /// Index of `U_iso` for `site`.
58    #[must_use]
59    pub const fn u_iso(self, site: usize) -> usize {
60        CELL_PARAMETER_COUNT + 4 * self.site_count + site
61    }
62
63    /// Index of the phase scale.
64    #[must_use]
65    pub const fn scale(self) -> usize {
66        CELL_PARAMETER_COUNT + 5 * self.site_count
67    }
68}
69
70/// Structure-factor values for a reflection batch.
71#[derive(Clone, Debug, PartialEq)]
72pub struct P1Values {
73    /// Real part of `F_h`.
74    pub f_real: Vec<f64>,
75    /// Imaginary part of `F_h`.
76    pub f_imag: Vec<f64>,
77    /// `scale * |F_h|^2` with multiplicity and corrections equal to one.
78    pub intensity: Vec<f64>,
79}
80
81/// Values and dense, parameter-major analytical derivatives.
82#[derive(Clone, Debug, PartialEq)]
83pub struct P1DenseResult {
84    /// Calculated values.
85    pub values: P1Values,
86    /// Parameter-major derivative of real `F`, shape `(P, R)`.
87    pub d_f_real: Vec<f64>,
88    /// Parameter-major derivative of imaginary `F`, shape `(P, R)`.
89    pub d_f_imag: Vec<f64>,
90    /// Parameter-major derivative of intensity, shape `(P, R)`.
91    pub d_intensity: Vec<f64>,
92    /// Parameter layout for derivative rows.
93    pub layout: P1ParameterLayout,
94}
95
96/// Values and one forward derivative product.
97#[derive(Clone, Debug, PartialEq)]
98pub struct P1JvpResult {
99    /// Calculated values.
100    pub values: P1Values,
101    /// Directional derivative of real `F`.
102    pub d_f_real: Vec<f64>,
103    /// Directional derivative of imaginary `F`.
104    pub d_f_imag: Vec<f64>,
105    /// Directional derivative of intensity.
106    pub d_intensity: Vec<f64>,
107}
108
109/// Values and one reverse intensity derivative product.
110#[derive(Clone, Debug, PartialEq)]
111pub struct P1VjpResult {
112    /// Calculated values.
113    pub values: P1Values,
114    /// `J_intensity^T weights` in stable parameter order.
115    pub gradient: Vec<f64>,
116    /// Parameter layout for the gradient.
117    pub layout: P1ParameterLayout,
118}
119
120/// Invalid P1 batch or derivative input.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum P1BatchError {
123    /// The unit cell is invalid.
124    Cell(CellError),
125    /// Site arrays do not have a common length.
126    SiteLengthMismatch,
127    /// Scattering arrays are not exactly reflection count times site count.
128    ScatteringShapeMismatch,
129    /// A coordinate, occupancy, displacement, amplitude, or scale is non-finite.
130    NonFiniteInput,
131    /// Occupancy, displacement, or scale is negative.
132    NegativePhysicalParameter,
133    /// A tangent does not match the parameter layout.
134    TangentLengthMismatch,
135    /// Reverse weights do not match the reflection count.
136    WeightLengthMismatch,
137    /// A dense derivative allocation would overflow addressable memory.
138    AllocationOverflow,
139}
140
141impl Display for P1BatchError {
142    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::Cell(error) => Display::fmt(error, formatter),
145            Self::SiteLengthMismatch => {
146                formatter.write_str("P1 site arrays must have equal length")
147            }
148            Self::ScatteringShapeMismatch => formatter
149                .write_str("P1 scattering arrays must have reflection_count * site_count elements"),
150            Self::NonFiniteInput => {
151                formatter.write_str("P1 inputs must contain only finite values")
152            }
153            Self::NegativePhysicalParameter => {
154                formatter.write_str("P1 occupancy, U_iso, and scale must be non-negative")
155            }
156            Self::TangentLengthMismatch => {
157                formatter.write_str("P1 tangent length must equal the parameter count")
158            }
159            Self::WeightLengthMismatch => {
160                formatter.write_str("P1 reverse weights must match the reflection count")
161            }
162            Self::AllocationOverflow => formatter.write_str("P1 derivative allocation overflow"),
163        }
164    }
165}
166
167impl Error for P1BatchError {}
168
169impl From<CellError> for P1BatchError {
170    fn from(value: CellError) -> Self {
171        Self::Cell(value)
172    }
173}
174
175struct ValidatedP1<'a> {
176    batch: P1BatchView<'a>,
177    geometry: CellGeometry,
178    layout: P1ParameterLayout,
179}
180
181/// Calculate P1 values without structural derivative storage.
182///
183/// # Errors
184///
185/// Returns [`P1BatchError`] for an invalid cell, inconsistent array shapes,
186/// non-finite inputs, negative physical parameters, or size overflow.
187pub fn calculate_p1_values(
188    cell: UnitCell,
189    batch: P1BatchView<'_>,
190) -> Result<P1Values, P1BatchError> {
191    let validated = validate(cell, batch)?;
192    let mut values = empty_values(batch.hkl.len());
193    for reflection in 0..batch.hkl.len() {
194        let (f_real, f_imag) = reflection_value(&validated, reflection);
195        values.f_real[reflection] = f_real;
196        values.f_imag[reflection] = f_imag;
197        values.intensity[reflection] = batch.scale * (f_real * f_real + f_imag * f_imag);
198    }
199    Ok(values)
200}
201
202/// Calculate P1 values and a dense parameter-major Jacobian.
203///
204/// # Errors
205///
206/// Returns [`P1BatchError`] for an invalid cell, inconsistent array shapes,
207/// non-finite inputs, negative physical parameters, or size overflow.
208pub fn calculate_p1_dense(
209    cell: UnitCell,
210    batch: P1BatchView<'_>,
211) -> Result<P1DenseResult, P1BatchError> {
212    let validated = validate(cell, batch)?;
213    let reflection_count = batch.hkl.len();
214    let parameter_count = validated.layout.parameter_count();
215    let element_count = parameter_count
216        .checked_mul(reflection_count)
217        .ok_or(P1BatchError::AllocationOverflow)?;
218    let mut result = P1DenseResult {
219        values: empty_values(reflection_count),
220        d_f_real: vec![0.0; element_count],
221        d_f_imag: vec![0.0; element_count],
222        d_intensity: vec![0.0; element_count],
223        layout: validated.layout,
224    };
225    for reflection in 0..reflection_count {
226        calculate_dense_reflection(&validated, reflection, &mut result);
227    }
228    Ok(result)
229}
230
231/// Calculate values and one forward directional derivative without building a
232/// dense Jacobian.
233///
234/// # Errors
235///
236/// Returns [`P1BatchError`] for invalid batch data or when `tangent` does not
237/// match the stable parameter layout.
238pub fn calculate_p1_jvp(
239    cell: UnitCell,
240    batch: P1BatchView<'_>,
241    tangent: &[f64],
242) -> Result<P1JvpResult, P1BatchError> {
243    let validated = validate(cell, batch)?;
244    if tangent.len() != validated.layout.parameter_count() {
245        return Err(P1BatchError::TangentLengthMismatch);
246    }
247    if tangent.iter().any(|value| !value.is_finite()) {
248        return Err(P1BatchError::NonFiniteInput);
249    }
250    let reflection_count = batch.hkl.len();
251    let mut result = P1JvpResult {
252        values: empty_values(reflection_count),
253        d_f_real: vec![0.0; reflection_count],
254        d_f_imag: vec![0.0; reflection_count],
255        d_intensity: vec![0.0; reflection_count],
256    };
257    for reflection in 0..reflection_count {
258        calculate_jvp_reflection(&validated, reflection, tangent, &mut result);
259    }
260    Ok(result)
261}
262
263/// Calculate values and `J_intensity^T weights` without building a dense
264/// Jacobian.
265///
266/// # Errors
267///
268/// Returns [`P1BatchError`] for invalid batch data or when `weights` does not
269/// match the reflection count.
270pub fn calculate_p1_intensity_vjp(
271    cell: UnitCell,
272    batch: P1BatchView<'_>,
273    weights: &[f64],
274) -> Result<P1VjpResult, P1BatchError> {
275    let validated = validate(cell, batch)?;
276    if weights.len() != batch.hkl.len() {
277        return Err(P1BatchError::WeightLengthMismatch);
278    }
279    if weights.iter().any(|value| !value.is_finite()) {
280        return Err(P1BatchError::NonFiniteInput);
281    }
282    let mut result = P1VjpResult {
283        values: empty_values(batch.hkl.len()),
284        gradient: vec![0.0; validated.layout.parameter_count()],
285        layout: validated.layout,
286    };
287    let mut site_amplitudes = Vec::new();
288    site_amplitudes
289        .try_reserve_exact(validated.layout.site_count)
290        .map_err(|_| P1BatchError::AllocationOverflow)?;
291    for (reflection, weight) in weights.iter().copied().enumerate() {
292        calculate_vjp_reflection(
293            &validated,
294            reflection,
295            weight,
296            &mut site_amplitudes,
297            &mut result,
298        );
299    }
300    Ok(result)
301}
302
303fn validate(cell: UnitCell, batch: P1BatchView<'_>) -> Result<ValidatedP1<'_>, P1BatchError> {
304    let geometry = cell.geometry()?;
305    let site_count = batch.fractional_xyz.len();
306    if batch.occupancy.len() != site_count || batch.u_iso_angstrom2.len() != site_count {
307        return Err(P1BatchError::SiteLengthMismatch);
308    }
309    let scattering_count = batch
310        .hkl
311        .len()
312        .checked_mul(site_count)
313        .ok_or(P1BatchError::AllocationOverflow)?;
314    if batch.scattering_real.len() != scattering_count
315        || batch.scattering_imag.len() != scattering_count
316    {
317        return Err(P1BatchError::ScatteringShapeMismatch);
318    }
319    if !batch.scale.is_finite()
320        || batch
321            .fractional_xyz
322            .iter()
323            .flatten()
324            .chain(batch.occupancy)
325            .chain(batch.u_iso_angstrom2)
326            .chain(batch.scattering_real)
327            .chain(batch.scattering_imag)
328            .any(|value| !value.is_finite())
329    {
330        return Err(P1BatchError::NonFiniteInput);
331    }
332    if batch.scale < 0.0
333        || batch.occupancy.iter().any(|value| *value < 0.0)
334        || batch.u_iso_angstrom2.iter().any(|value| *value < 0.0)
335    {
336        return Err(P1BatchError::NegativePhysicalParameter);
337    }
338    Ok(ValidatedP1 {
339        batch,
340        geometry,
341        layout: P1ParameterLayout { site_count },
342    })
343}
344
345fn empty_values(reflection_count: usize) -> P1Values {
346    P1Values {
347        f_real: vec![0.0; reflection_count],
348        f_imag: vec![0.0; reflection_count],
349        intensity: vec![0.0; reflection_count],
350    }
351}
352
353fn atom_amplitude(
354    validated: &ValidatedP1<'_>,
355    reflection: usize,
356    site: usize,
357    q_squared: f64,
358) -> (f64, f64, f64, f64) {
359    let batch = validated.batch;
360    let scattering_index = reflection * validated.layout.site_count + site;
361    let phase = TWO_PI
362        * batch.hkl[reflection]
363            .iter()
364            .zip(batch.fractional_xyz[site])
365            .map(|(index, coordinate)| f64::from(*index) * coordinate)
366            .sum::<f64>();
367    let (sin_phase, cos_phase) = phase.sin_cos();
368    let scattering_real = batch.scattering_real[scattering_index];
369    let scattering_imag = batch.scattering_imag[scattering_index];
370    let rotated_real = scattering_real * cos_phase - scattering_imag * sin_phase;
371    let rotated_imag = scattering_real * sin_phase + scattering_imag * cos_phase;
372    let displacement = (-TWO_PI_SQUARED * batch.u_iso_angstrom2[site] * q_squared).exp();
373    let base_real = displacement * rotated_real;
374    let base_imag = displacement * rotated_imag;
375    (
376        base_real,
377        base_imag,
378        batch.occupancy[site] * base_real,
379        batch.occupancy[site] * base_imag,
380    )
381}
382
383fn reflection_value(validated: &ValidatedP1<'_>, reflection: usize) -> (f64, f64) {
384    let q_squared = validated
385        .geometry
386        .q_squared(validated.batch.hkl[reflection]);
387    let mut f_real = 0.0;
388    let mut f_imag = 0.0;
389    for site in 0..validated.layout.site_count {
390        let (_, _, contribution_real, contribution_imag) =
391            atom_amplitude(validated, reflection, site, q_squared);
392        f_real += contribution_real;
393        f_imag += contribution_imag;
394    }
395    (f_real, f_imag)
396}
397
398fn calculate_dense_reflection(
399    validated: &ValidatedP1<'_>,
400    reflection: usize,
401    result: &mut P1DenseResult,
402) {
403    let batch = validated.batch;
404    let reflection_count = batch.hkl.len();
405    let (q_squared, d_q_squared) = validated
406        .geometry
407        .q_squared_and_derivatives(batch.hkl[reflection]);
408    let mut f_real = 0.0;
409    let mut f_imag = 0.0;
410    for site in 0..validated.layout.site_count {
411        let (base_real, base_imag, contribution_real, contribution_imag) =
412            atom_amplitude(validated, reflection, site, q_squared);
413        f_real += contribution_real;
414        f_imag += contribution_imag;
415        for (parameter, d_q) in d_q_squared.iter().copied().enumerate() {
416            let factor = -TWO_PI_SQUARED * batch.u_iso_angstrom2[site] * d_q;
417            set_derivative(
418                result,
419                parameter,
420                reflection,
421                reflection_count,
422                factor * contribution_real,
423                factor * contribution_imag,
424            );
425        }
426        for component in 0..3 {
427            let factor = TWO_PI * f64::from(batch.hkl[reflection][component]);
428            set_derivative(
429                result,
430                validated.layout.coordinate(site, component),
431                reflection,
432                reflection_count,
433                -factor * contribution_imag,
434                factor * contribution_real,
435            );
436        }
437        set_derivative(
438            result,
439            validated.layout.occupancy(site),
440            reflection,
441            reflection_count,
442            base_real,
443            base_imag,
444        );
445        let displacement_factor = -TWO_PI_SQUARED * q_squared;
446        set_derivative(
447            result,
448            validated.layout.u_iso(site),
449            reflection,
450            reflection_count,
451            displacement_factor * contribution_real,
452            displacement_factor * contribution_imag,
453        );
454    }
455    let norm = f_real * f_real + f_imag * f_imag;
456    result.values.f_real[reflection] = f_real;
457    result.values.f_imag[reflection] = f_imag;
458    result.values.intensity[reflection] = batch.scale * norm;
459    for parameter in 0..validated.layout.parameter_count() {
460        let index = parameter * reflection_count + reflection;
461        result.d_intensity[index] =
462            2.0 * batch.scale * (f_real * result.d_f_real[index] + f_imag * result.d_f_imag[index]);
463    }
464    result.d_intensity[validated.layout.scale() * reflection_count + reflection] = norm;
465}
466
467fn set_derivative(
468    result: &mut P1DenseResult,
469    parameter: usize,
470    reflection: usize,
471    reflection_count: usize,
472    real: f64,
473    imag: f64,
474) {
475    let index = parameter * reflection_count + reflection;
476    result.d_f_real[index] += real;
477    result.d_f_imag[index] += imag;
478}
479
480fn calculate_jvp_reflection(
481    validated: &ValidatedP1<'_>,
482    reflection: usize,
483    tangent: &[f64],
484    result: &mut P1JvpResult,
485) {
486    let batch = validated.batch;
487    let (q_squared, d_q_squared) = validated
488        .geometry
489        .q_squared_and_derivatives(batch.hkl[reflection]);
490    let d_q_direction = d_q_squared
491        .iter()
492        .zip(&tangent[..CELL_PARAMETER_COUNT])
493        .map(|(derivative, direction)| derivative * direction)
494        .sum::<f64>();
495    let mut f_real = 0.0;
496    let mut f_imag = 0.0;
497    let mut d_f_real = 0.0;
498    let mut d_f_imag = 0.0;
499    for site in 0..validated.layout.site_count {
500        let (base_real, base_imag, contribution_real, contribution_imag) =
501            atom_amplitude(validated, reflection, site, q_squared);
502        f_real += contribution_real;
503        f_imag += contribution_imag;
504        let phase_direction = TWO_PI
505            * (0..3)
506                .map(|component| {
507                    f64::from(batch.hkl[reflection][component])
508                        * tangent[validated.layout.coordinate(site, component)]
509                })
510                .sum::<f64>();
511        let displacement_direction = -TWO_PI_SQUARED
512            * (batch.u_iso_angstrom2[site] * d_q_direction
513                + q_squared * tangent[validated.layout.u_iso(site)]);
514        let occupancy_direction = tangent[validated.layout.occupancy(site)];
515        d_f_real += occupancy_direction * base_real + displacement_direction * contribution_real
516            - phase_direction * contribution_imag;
517        d_f_imag += occupancy_direction * base_imag
518            + displacement_direction * contribution_imag
519            + phase_direction * contribution_real;
520    }
521    let norm = f_real * f_real + f_imag * f_imag;
522    result.values.f_real[reflection] = f_real;
523    result.values.f_imag[reflection] = f_imag;
524    result.values.intensity[reflection] = batch.scale * norm;
525    result.d_f_real[reflection] = d_f_real;
526    result.d_f_imag[reflection] = d_f_imag;
527    result.d_intensity[reflection] = 2.0 * batch.scale * (f_real * d_f_real + f_imag * d_f_imag)
528        + tangent[validated.layout.scale()] * norm;
529}
530
531fn calculate_vjp_reflection(
532    validated: &ValidatedP1<'_>,
533    reflection: usize,
534    weight: f64,
535    site_amplitudes: &mut Vec<(f64, f64, f64, f64)>,
536    result: &mut P1VjpResult,
537) {
538    let batch = validated.batch;
539    let (q_squared, d_q_squared) = validated
540        .geometry
541        .q_squared_and_derivatives(batch.hkl[reflection]);
542    let mut f_real = 0.0;
543    let mut f_imag = 0.0;
544    site_amplitudes.clear();
545    for site in 0..validated.layout.site_count {
546        let amplitude = atom_amplitude(validated, reflection, site, q_squared);
547        f_real += amplitude.2;
548        f_imag += amplitude.3;
549        site_amplitudes.push(amplitude);
550    }
551    let norm = f_real * f_real + f_imag * f_imag;
552    result.values.f_real[reflection] = f_real;
553    result.values.f_imag[reflection] = f_imag;
554    result.values.intensity[reflection] = batch.scale * norm;
555    let intensity_factor = 2.0 * batch.scale * weight;
556    for (site, &(base_real, base_imag, contribution_real, contribution_imag)) in
557        site_amplitudes.iter().enumerate()
558    {
559        for (parameter, d_q) in d_q_squared.iter().copied().enumerate() {
560            let factor = -TWO_PI_SQUARED * batch.u_iso_angstrom2[site] * d_q;
561            result.gradient[parameter] += intensity_factor
562                * factor
563                * (f_real * contribution_real + f_imag * contribution_imag);
564        }
565        for component in 0..3 {
566            let factor = TWO_PI * f64::from(batch.hkl[reflection][component]);
567            let d_real = -factor * contribution_imag;
568            let d_imag = factor * contribution_real;
569            result.gradient[validated.layout.coordinate(site, component)] +=
570                intensity_factor * (f_real * d_real + f_imag * d_imag);
571        }
572        result.gradient[validated.layout.occupancy(site)] +=
573            intensity_factor * (f_real * base_real + f_imag * base_imag);
574        let displacement_factor = -TWO_PI_SQUARED * q_squared;
575        result.gradient[validated.layout.u_iso(site)] += intensity_factor
576            * displacement_factor
577            * (f_real * contribution_real + f_imag * contribution_imag);
578    }
579    result.gradient[validated.layout.scale()] += weight * norm;
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    fn cell() -> UnitCell {
587        UnitCell {
588            a_angstrom: 4.2,
589            b_angstrom: 5.1,
590            c_angstrom: 6.3,
591            alpha_deg: 79.0,
592            beta_deg: 83.0,
593            gamma_deg: 74.0,
594        }
595    }
596
597    #[test]
598    fn one_origin_atom_has_closed_form_value() {
599        let hkl = [[1, 2, 3], [0, 0, 0]];
600        let xyz = [[0.0, 0.0, 0.0]];
601        let occupancy = [0.75];
602        let u_iso = [0.0];
603        let real = [2.0, 3.0];
604        let imag = [-0.5, 0.25];
605        let values = calculate_p1_values(
606            cell(),
607            P1BatchView {
608                hkl: &hkl,
609                fractional_xyz: &xyz,
610                occupancy: &occupancy,
611                u_iso_angstrom2: &u_iso,
612                scattering_real: &real,
613                scattering_imag: &imag,
614                scale: 2.0,
615            },
616        )
617        .expect("P1 values");
618        assert_eq!(values.f_real, vec![1.5, 2.25]);
619        assert_eq!(values.f_imag, vec![-0.375, 0.1875]);
620        assert!((values.intensity[0] - 2.0 * (1.5_f64.powi(2) + 0.375_f64.powi(2))).abs() < 1e-15);
621    }
622
623    #[test]
624    fn lattice_translation_and_origin_shift_preserve_intensity() {
625        let hkl = [[1, -2, 3], [2, 1, -1]];
626        let occupancy = [0.8, 0.6];
627        let u_iso = [0.01, 0.02];
628        let real = [2.0, 1.0, 1.5, 0.7];
629        let imag = [0.1, -0.2, 0.05, 0.15];
630        let original_xyz = [[0.17, 0.29, 0.43], [0.61, 0.11, 0.37]];
631        let shifted_xyz = [[1.37, -0.01, 2.83], [1.81, -0.19, 2.77]];
632        let original = calculate_p1_values(
633            cell(),
634            P1BatchView {
635                hkl: &hkl,
636                fractional_xyz: &original_xyz,
637                occupancy: &occupancy,
638                u_iso_angstrom2: &u_iso,
639                scattering_real: &real,
640                scattering_imag: &imag,
641                scale: 1.3,
642            },
643        )
644        .expect("original");
645        let shifted = calculate_p1_values(
646            cell(),
647            P1BatchView {
648                hkl: &hkl,
649                fractional_xyz: &shifted_xyz,
650                occupancy: &occupancy,
651                u_iso_angstrom2: &u_iso,
652                scattering_real: &real,
653                scattering_imag: &imag,
654                scale: 1.3,
655            },
656        )
657        .expect("shifted");
658        for (left, right) in original.intensity.iter().zip(shifted.intensity) {
659            assert!((left - right).abs() < 2.0e-13 * left.abs().max(1.0));
660        }
661    }
662
663    #[test]
664    fn jvp_and_vjp_match_dense_jacobian() {
665        let hkl = [[1, 0, 1], [2, -1, 3], [-1, 2, 2]];
666        let xyz = [[0.17, 0.29, 0.43], [0.61, 0.11, 0.37]];
667        let occupancy = [0.8, 0.6];
668        let u_iso = [0.01, 0.02];
669        let real = [2.0, 1.0, 1.5, 0.7, 0.9, 1.2];
670        let imag = [0.1, -0.2, 0.05, 0.15, -0.1, 0.2];
671        let batch = P1BatchView {
672            hkl: &hkl,
673            fractional_xyz: &xyz,
674            occupancy: &occupancy,
675            u_iso_angstrom2: &u_iso,
676            scattering_real: &real,
677            scattering_imag: &imag,
678            scale: 1.3,
679        };
680        let dense = calculate_p1_dense(cell(), batch).expect("dense");
681        let tangent: Vec<f64> = (0..dense.layout.parameter_count())
682            .map(|index| {
683                (f64::from(u32::try_from(index).expect("small test parameter count")) + 1.0)
684                    * 1.0e-3
685            })
686            .collect();
687        let weights = [0.3, -0.7, 1.1];
688        let jvp = calculate_p1_jvp(cell(), batch, &tangent).expect("jvp");
689        let vjp = calculate_p1_intensity_vjp(cell(), batch, &weights).expect("vjp");
690        for reflection in 0..hkl.len() {
691            let expected = (0..dense.layout.parameter_count())
692                .map(|parameter| {
693                    dense.d_intensity[parameter * hkl.len() + reflection] * tangent[parameter]
694                })
695                .sum::<f64>();
696            assert!((jvp.d_intensity[reflection] - expected).abs() < 2.0e-12);
697        }
698        for parameter in 0..dense.layout.parameter_count() {
699            let expected = (0..hkl.len())
700                .map(|reflection| {
701                    dense.d_intensity[parameter * hkl.len() + reflection] * weights[reflection]
702                })
703                .sum::<f64>();
704            assert!((vjp.gradient[parameter] - expected).abs() < 2.0e-12);
705        }
706        let forward_dot = jvp
707            .d_intensity
708            .iter()
709            .zip(weights)
710            .map(|(value, weight)| value * weight)
711            .sum::<f64>();
712        let reverse_dot = vjp
713            .gradient
714            .iter()
715            .zip(tangent)
716            .map(|(value, direction)| value * direction)
717            .sum::<f64>();
718        assert!((forward_dot - reverse_dot).abs() < 2.0e-12);
719    }
720
721    #[test]
722    #[allow(clippy::too_many_lines)]
723    fn dense_derivatives_match_centered_differences() {
724        let hkl = [[1, 2, -1]];
725        let xyz = [[0.17, 0.29, 0.43]];
726        let occupancy = [0.8];
727        let u_iso = [0.01];
728        let real = [2.0];
729        let imag = [0.1];
730        let base_cell = cell();
731        let layout = P1ParameterLayout { site_count: 1 };
732        let evaluate =
733            |cell: UnitCell, xyz: &[[f64; 3]], occupancy: &[f64], u_iso: &[f64], scale| {
734                calculate_p1_values(
735                    cell,
736                    P1BatchView {
737                        hkl: &hkl,
738                        fractional_xyz: xyz,
739                        occupancy,
740                        u_iso_angstrom2: u_iso,
741                        scattering_real: &real,
742                        scattering_imag: &imag,
743                        scale,
744                    },
745                )
746                .expect("values")
747                .intensity[0]
748            };
749        let batch = P1BatchView {
750            hkl: &hkl,
751            fractional_xyz: &xyz,
752            occupancy: &occupancy,
753            u_iso_angstrom2: &u_iso,
754            scattering_real: &real,
755            scattering_imag: &imag,
756            scale: 1.3,
757        };
758        let dense = calculate_p1_dense(base_cell, batch).expect("dense");
759        for parameter in 0..layout.parameter_count() {
760            let step = if parameter < 3 { 1e-6 } else { 1e-7 };
761            let mut plus_cell = base_cell;
762            let mut minus_cell = base_cell;
763            let mut plus_xyz = xyz;
764            let mut minus_xyz = xyz;
765            let mut plus_occupancy = occupancy;
766            let mut minus_occupancy = occupancy;
767            let mut plus_u = u_iso;
768            let mut minus_u = u_iso;
769            let mut plus_scale = 1.3;
770            let mut minus_scale = 1.3;
771            match parameter {
772                0 => {
773                    plus_cell.a_angstrom += step;
774                    minus_cell.a_angstrom -= step;
775                }
776                1 => {
777                    plus_cell.b_angstrom += step;
778                    minus_cell.b_angstrom -= step;
779                }
780                2 => {
781                    plus_cell.c_angstrom += step;
782                    minus_cell.c_angstrom -= step;
783                }
784                3 => {
785                    plus_cell.alpha_deg += step;
786                    minus_cell.alpha_deg -= step;
787                }
788                4 => {
789                    plus_cell.beta_deg += step;
790                    minus_cell.beta_deg -= step;
791                }
792                5 => {
793                    plus_cell.gamma_deg += step;
794                    minus_cell.gamma_deg -= step;
795                }
796                value if value == layout.coordinate(0, 0) => {
797                    plus_xyz[0][0] += step;
798                    minus_xyz[0][0] -= step;
799                }
800                value if value == layout.coordinate(0, 1) => {
801                    plus_xyz[0][1] += step;
802                    minus_xyz[0][1] -= step;
803                }
804                value if value == layout.coordinate(0, 2) => {
805                    plus_xyz[0][2] += step;
806                    minus_xyz[0][2] -= step;
807                }
808                value if value == layout.occupancy(0) => {
809                    plus_occupancy[0] += step;
810                    minus_occupancy[0] -= step;
811                }
812                value if value == layout.u_iso(0) => {
813                    plus_u[0] += step;
814                    minus_u[0] -= step;
815                }
816                value if value == layout.scale() => {
817                    plus_scale += step;
818                    minus_scale -= step;
819                }
820                _ => unreachable!(),
821            }
822            let plus = evaluate(plus_cell, &plus_xyz, &plus_occupancy, &plus_u, plus_scale);
823            let minus = evaluate(
824                minus_cell,
825                &minus_xyz,
826                &minus_occupancy,
827                &minus_u,
828                minus_scale,
829            );
830            let finite = (plus - minus) / (2.0 * step);
831            assert!((dense.d_intensity[parameter] - finite).abs() < 3.0e-7 * finite.abs().max(1.0));
832        }
833    }
834
835    #[test]
836    fn invalid_batch_shapes_are_rejected() {
837        let hkl = [[1, 0, 0]];
838        let xyz = [[0.0, 0.0, 0.0]];
839        let occupancy = [1.0];
840        let empty = [];
841        let error = calculate_p1_values(
842            cell(),
843            P1BatchView {
844                hkl: &hkl,
845                fractional_xyz: &xyz,
846                occupancy: &occupancy,
847                u_iso_angstrom2: &empty,
848                scattering_real: &empty,
849                scattering_imag: &empty,
850                scale: 1.0,
851            },
852        )
853        .expect_err("invalid lengths");
854        assert_eq!(error, P1BatchError::SiteLengthMismatch);
855    }
856}