Skip to main content

fpm_rs/model/
image_plane_fpm.rs

1use ndarray::{ArrayView2, ArrayViewMut2};
2use num_complex::Complex64;
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::{
6    Result,
7    array_layout::{StandardView2, checked_len_2d},
8    error::Error,
9    experiment::{
10        AcquisitionPlan, Illumination, KVector, MultiplexingMatrix, Optics, ResolvedIllumination,
11    },
12};
13
14use super::{CropIndices, FourierCrop, FourierOffset, Pupil, Sampling};
15
16/// Selects an explicit reconstruction grid or an automatic sizing strategy.
17///
18/// Automatic shapes preserve the low-resolution aspect ratio, so the recovered
19/// object has the same pixel size in both axes. [`Self::Smooth`] and
20/// [`Self::PowerOfTwo`] round the shared reduced-aspect-ratio multiplier rather
21/// than each dimension independently.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ReconstructionShape {
24    /// Use the supplied concrete `(height, width)` after validating it.
25    Exact((usize, usize)),
26    /// Use the smallest grid containing every Fourier crop and interpolation
27    /// stencil.
28    Minimum,
29    /// Round the minimum shared multiplier up to a value whose prime factors
30    /// are limited to 2, 3, 5, and 7.
31    Smooth,
32    /// Round the minimum shared multiplier up to a power of two.
33    PowerOfTwo,
34}
35
36#[derive(Clone, Copy, Debug)]
37struct GridShift {
38    integer: isize,
39    offset: f64,
40    lower: isize,
41    upper: isize,
42}
43
44#[derive(Clone, Copy, Debug)]
45pub(crate) struct CropDisplacementBounds {
46    minimum_row: isize,
47    maximum_row: isize,
48    minimum_column: isize,
49    maximum_column: isize,
50}
51
52/// Algorithm-facing image-plane FPM model. It contains no LED or camera geometry.
53///
54/// Source-indexed vectors, crops, and optional subpixel offsets describe individual
55/// illuminations. Acquisition-frame-indexed gains, background, and multiplexing describe
56/// measured frames. Shapes are `(height, width)` and stored arrays own their data.
57#[derive(Clone, Debug, Serialize)]
58pub struct ImagePlaneModel {
59    /// One transverse wave vector per illumination source.
60    pub(crate) k_vectors: Vec<KVector>,
61    pub(crate) pupil: Pupil,
62    pub(crate) crop_indices: CropIndices,
63    /// Fractional `(row, column)` Fourier-grid offsets relative to each crop.
64    #[serde(default)]
65    pub(crate) subpixel_offsets: Option<Vec<FourierOffset>>,
66    pub(crate) sampling: Sampling,
67    pub(crate) image_shape: (usize, usize),
68    pub(crate) reconstruction_shape: (usize, usize),
69    pub(crate) frame_gains: Option<Vec<f64>>,
70    pub(crate) background: Option<Vec<f64>>,
71    /// Optional measured-frame rows of `(source_index, incoherent_weight)`.
72    pub(crate) multiplexing_matrix: Option<MultiplexingMatrix>,
73}
74
75impl<'de> Deserialize<'de> for ImagePlaneModel {
76    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        use serde::de::Error as _;
81
82        #[derive(Deserialize)]
83        #[serde(deny_unknown_fields)]
84        struct Representation {
85            k_vectors: Vec<KVector>,
86            pupil: Pupil,
87            crop_indices: CropIndices,
88            #[serde(default)]
89            subpixel_offsets: Option<Vec<FourierOffset>>,
90            sampling: Sampling,
91            image_shape: (usize, usize),
92            reconstruction_shape: (usize, usize),
93            frame_gains: Option<Vec<f64>>,
94            background: Option<Vec<f64>>,
95            multiplexing_matrix: Option<MultiplexingMatrix>,
96        }
97
98        let representation = Representation::deserialize(deserializer)?;
99        let model = Self {
100            k_vectors: representation.k_vectors,
101            pupil: representation.pupil,
102            crop_indices: representation.crop_indices,
103            subpixel_offsets: representation.subpixel_offsets,
104            sampling: representation.sampling,
105            image_shape: representation.image_shape,
106            reconstruction_shape: representation.reconstruction_shape,
107            frame_gains: representation.frame_gains,
108            background: representation.background,
109            multiplexing_matrix: representation.multiplexing_matrix,
110        };
111        model.validate().map_err(D::Error::custom)?;
112        Ok(model)
113    }
114}
115
116impl ImagePlaneModel {
117    /// Builds and validates a non-multiplexed model from explicitly compiled components.
118    ///
119    /// `k_vectors` and `crop_indices` must have equal non-zero source counts. `pupil`
120    /// and each crop must have `image_shape`; `sampling` and `reconstruction_shape`
121    /// must be mutually consistent.
122    pub fn new(
123        k_vectors: Vec<KVector>,
124        pupil: Pupil,
125        crop_indices: CropIndices,
126        sampling: Sampling,
127        image_shape: (usize, usize),
128        reconstruction_shape: (usize, usize),
129    ) -> Result<Self> {
130        let model = Self {
131            k_vectors,
132            pupil,
133            crop_indices,
134            subpixel_offsets: None,
135            sampling,
136            image_shape,
137            reconstruction_shape,
138            frame_gains: None,
139            background: None,
140            multiplexing_matrix: None,
141        };
142        model.validate()?;
143        Ok(model)
144    }
145
146    /// Compiles an experiment using an explicit or automatically selected
147    /// reconstruction shape.
148    pub fn from_experiment(
149        optics: &Optics,
150        illumination: &Illumination,
151        image_shape: (usize, usize),
152        reconstruction_shape: ReconstructionShape,
153    ) -> Result<Self> {
154        optics.validate()?;
155        let resolved = illumination.resolve(optics)?;
156        Self::compile(optics, &resolved, image_shape, reconstruction_shape)
157    }
158
159    /// Resolves an explicit shape or suggests an automatic reconstruction grid
160    /// from the actual illumination wave vectors.
161    pub fn suggest_reconstruction_shape(
162        optics: &Optics,
163        illumination: &Illumination,
164        image_shape: (usize, usize),
165        reconstruction_shape: ReconstructionShape,
166    ) -> Result<(usize, usize)> {
167        optics.validate()?;
168        let resolved = illumination.resolve(optics)?;
169        let bounds = Self::crop_displacement_bounds(optics, image_shape, resolved.k_vectors())?;
170        Self::resolve_reconstruction_shape(image_shape, reconstruction_shape, &[bounds])
171    }
172
173    /// Compiles already resolved illumination into algorithm-facing numerical state.
174    ///
175    /// Geometry positions and poses are intentionally not retained. Sparse frame
176    /// weights are multiplied by stable source powers during compilation.
177    pub fn compile(
178        optics: &Optics,
179        illumination: &ResolvedIllumination,
180        image_shape: (usize, usize),
181        reconstruction_shape: ReconstructionShape,
182    ) -> Result<Self> {
183        optics.validate()?;
184        let k_vectors = illumination.k_vectors().to_vec();
185        let bounds = Self::crop_displacement_bounds(optics, image_shape, &k_vectors)?;
186        let reconstruction_shape =
187            Self::resolve_reconstruction_shape(image_shape, reconstruction_shape, &[bounds])?;
188        let scale = reconstruction_shape.1 as f64 / image_shape.1 as f64;
189        let low_res_pixel_size = optics.object_pixel_size();
190        let mut sampling = Sampling::new(
191            low_res_pixel_size,
192            low_res_pixel_size / scale,
193            std::f64::consts::TAU / (image_shape.1 as f64 * low_res_pixel_size),
194            std::f64::consts::TAU / (image_shape.0 as f64 * low_res_pixel_size),
195        )?;
196        sampling.wavelength = Some(optics.wavelength_vacuum_m);
197        let maximum_illumination_na = k_vectors
198            .iter()
199            .map(|vector| {
200                vector.kx.hypot(vector.ky) * optics.wavelength_vacuum_m / std::f64::consts::TAU
201            })
202            .fold(0.0, f64::max);
203        sampling.synthetic_na = Some(optics.objective_na + maximum_illumination_na);
204        let pupil = Pupil::circular(image_shape, &sampling, optics)?;
205        let mut offsets = Vec::with_capacity(k_vectors.len());
206        let crops = k_vectors
207            .iter()
208            .map(|vector| {
209                let (crop, offset) =
210                    Self::crop_for_k_vector(vector, &sampling, image_shape, reconstruction_shape)?;
211                offsets.push(offset);
212                Ok(crop)
213            })
214            .collect::<Result<Vec<_>>>()?;
215        let mut model = Self::new(
216            k_vectors,
217            pupil,
218            CropIndices::new(crops),
219            sampling,
220            image_shape,
221            reconstruction_shape,
222        )?;
223        model.frame_gains = Some(illumination.frame_gains());
224        let matrix = illumination.compiled_multiplexing_matrix();
225        let identity = matrix.len() == model.source_count()
226            && matrix
227                .iter()
228                .enumerate()
229                .all(|(source, row)| row.as_slice() == [(source, 1.0)]);
230        model.multiplexing_matrix = (!identity).then_some(matrix);
231        model.subpixel_offsets = Some(offsets);
232        model.validate()?;
233        Ok(model)
234    }
235
236    pub(crate) fn crop_displacement_bounds(
237        optics: &Optics,
238        image_shape: (usize, usize),
239        k_vectors: &[KVector],
240    ) -> Result<CropDisplacementBounds> {
241        optics.validate()?;
242        validate_image_shape(image_shape)?;
243        if k_vectors.is_empty() {
244            return Err(Error::InvalidModel(
245                "illumination must contain at least one frame".into(),
246            ));
247        }
248        let low_res_pixel_size = optics.object_pixel_size();
249        let dkx = std::f64::consts::TAU / (image_shape.1 as f64 * low_res_pixel_size);
250        let dky = std::f64::consts::TAU / (image_shape.0 as f64 * low_res_pixel_size);
251        let mut bounds = CropDisplacementBounds {
252            minimum_row: isize::MAX,
253            maximum_row: isize::MIN,
254            minimum_column: isize::MAX,
255            maximum_column: isize::MIN,
256        };
257        for vector in k_vectors {
258            let row = checked_grid_shift(vector.ky / dky)?;
259            let column = checked_grid_shift(vector.kx / dkx)?;
260            bounds.minimum_row = bounds.minimum_row.min(row.lower);
261            bounds.maximum_row = bounds.maximum_row.max(row.upper);
262            bounds.minimum_column = bounds.minimum_column.min(column.lower);
263            bounds.maximum_column = bounds.maximum_column.max(column.upper);
264        }
265        Ok(bounds)
266    }
267
268    pub(crate) fn resolve_reconstruction_shape(
269        image_shape: (usize, usize),
270        reconstruction_shape: ReconstructionShape,
271        bounds: &[CropDisplacementBounds],
272    ) -> Result<(usize, usize)> {
273        validate_image_shape(image_shape)?;
274        if bounds.is_empty() {
275            return Err(Error::InvalidModel(
276                "at least one set of illumination crop bounds is required".into(),
277            ));
278        }
279        match reconstruction_shape {
280            ReconstructionShape::Exact(shape) => {
281                validate_reconstruction_aspect(image_shape, shape)?;
282                if !shape_contains_bounds(image_shape, shape, bounds)? {
283                    return Err(Error::InvalidShape(format!(
284                        "reconstruction shape {shape:?} does not contain every illumination crop and subpixel interpolation stencil"
285                    )));
286                }
287                Ok(shape)
288            }
289            policy => {
290                let divisor = greatest_common_divisor(image_shape.0, image_shape.1);
291                let aspect_height = image_shape.0 / divisor;
292                let aspect_width = image_shape.1 / divisor;
293                let maximum_multiplier =
294                    (isize::MAX as usize / aspect_height).min(isize::MAX as usize / aspect_width);
295                let minimum_multiplier = minimum_fitting_multiplier(
296                    image_shape,
297                    (aspect_height, aspect_width),
298                    divisor,
299                    maximum_multiplier,
300                    bounds,
301                )?;
302                let multiplier = match policy {
303                    ReconstructionShape::Minimum => minimum_multiplier,
304                    ReconstructionShape::Smooth => next_smooth_multiplier(minimum_multiplier)
305                        .filter(|&value| value <= maximum_multiplier)
306                        .ok_or_else(|| {
307                            Error::InvalidShape(
308                                "no supported 2/3/5/7-smooth reconstruction multiplier exists"
309                                    .into(),
310                            )
311                        })?,
312                    ReconstructionShape::PowerOfTwo => minimum_multiplier
313                        .checked_next_power_of_two()
314                        .filter(|&value| value <= maximum_multiplier)
315                        .ok_or_else(|| {
316                            Error::InvalidShape(
317                                "no supported power-of-two reconstruction multiplier exists".into(),
318                            )
319                        })?,
320                    ReconstructionShape::Exact(shape) => {
321                        return Err(Error::InvalidShape(format!(
322                            "unexpected exact reconstruction shape {shape:?} during automatic sizing"
323                        )));
324                    }
325                };
326                candidate_shape((aspect_height, aspect_width), multiplier)
327            }
328        }
329    }
330
331    /// Returns the number of individual illumination sources.
332    pub fn source_count(&self) -> usize {
333        self.k_vectors.len()
334    }
335
336    /// Returns the acquisition-frame count, which can differ when sources are multiplexed.
337    pub fn frame_count(&self) -> usize {
338        self.multiplexing_matrix
339            .as_ref()
340            .map_or_else(|| self.source_count(), Vec::len)
341    }
342
343    /// Returns whether acquisition frames contain incoherent combinations of sources.
344    pub fn is_multiplexed(&self) -> bool {
345        self.multiplexing_matrix
346            .as_ref()
347            .is_some_and(|matrix| matrix.iter().any(|row| row.len() > 1))
348    }
349
350    /// Borrows transverse wave vectors in source order, in radians per metre.
351    pub fn k_vectors(&self) -> &[KVector] {
352        &self.k_vectors
353    }
354
355    /// Borrows the low-resolution complex pupil and binary support.
356    pub fn pupil(&self) -> &Pupil {
357        &self.pupil
358    }
359
360    /// Mutably borrows the pupil; callers must preserve its shape and support invariants.
361    pub fn pupil_mut(&mut self) -> &mut Pupil {
362        &mut self.pupil
363    }
364
365    /// Borrows integer Fourier crops in individual source order.
366    pub fn crop_indices(&self) -> &CropIndices {
367        &self.crop_indices
368    }
369
370    /// Borrows optional fractional `(row, column)` offsets in source order.
371    pub fn subpixel_offsets(&self) -> Option<&[FourierOffset]> {
372        self.subpixel_offsets.as_deref()
373    }
374
375    /// Borrows real- and Fourier-space sampling metadata.
376    pub const fn sampling(&self) -> &Sampling {
377        &self.sampling
378    }
379
380    /// Returns low-resolution detector shape as `(height, width)`.
381    pub const fn image_shape(&self) -> (usize, usize) {
382        self.image_shape
383    }
384
385    /// Returns high-resolution object shape as `(height, width)`.
386    pub const fn reconstruction_shape(&self) -> (usize, usize) {
387        self.reconstruction_shape
388    }
389
390    /// Borrows optional non-negative multiplicative gains in acquisition-frame order.
391    pub fn frame_gains(&self) -> Option<&[f64]> {
392        self.frame_gains.as_deref()
393    }
394
395    /// Borrows optional non-negative optical intensity background.
396    ///
397    /// Length is one low-resolution frame (broadcast) or a complete row-major
398    /// `(frame, row, column)` stack.
399    pub fn background(&self) -> Option<&[f64]> {
400        self.background.as_deref()
401    }
402
403    /// Borrows optional acquisition-frame rows of non-negative source weights.
404    pub fn multiplexing_matrix(&self) -> Option<&MultiplexingMatrix> {
405        self.multiplexing_matrix.as_ref()
406    }
407
408    /// Replaces gains, requiring one finite non-negative value per acquisition frame.
409    pub fn with_frame_gains(mut self, values: Option<Vec<f64>>) -> Result<Self> {
410        self.frame_gains = values;
411        self.validate()?;
412        Ok(self)
413    }
414
415    /// Refreshes only source powers, sparse acquisition weights, and frame gains.
416    ///
417    /// Physical positions, propagation vectors, Fourier crops, subpixel offsets,
418    /// pupil samples, sampling metadata, and reconstruction shapes are retained.
419    /// This is the intensity-only update boundary used by physical illumination
420    /// calibration.
421    pub fn update_intensity_calibration(
422        &mut self,
423        source_power: &[f64],
424        acquisition: &AcquisitionPlan,
425    ) -> Result<()> {
426        if source_power.len() != self.source_count()
427            || source_power
428                .iter()
429                .any(|value| !value.is_finite() || *value < 0.0)
430        {
431            return Err(Error::InvalidParameter {
432                name: "source_power",
433                reason: format!(
434                    "must contain {} finite non-negative values",
435                    self.source_count()
436                ),
437            });
438        }
439        if acquisition.frame_count() != self.frame_count() {
440            return Err(Error::InvalidParameter {
441                name: "acquisition",
442                reason: format!(
443                    "frame count {} differs from compiled frame count {}",
444                    acquisition.frame_count(),
445                    self.frame_count()
446                ),
447            });
448        }
449        let mut matrix = Vec::with_capacity(acquisition.frame_count());
450        let mut gains = Vec::with_capacity(acquisition.frame_count());
451        for frame in acquisition.frames() {
452            let mut row = Vec::with_capacity(frame.contributions.len());
453            for contribution in &frame.contributions {
454                let power = source_power.get(contribution.source).ok_or_else(|| {
455                    Error::InvalidParameter {
456                        name: "acquisition",
457                        reason: format!(
458                            "source index {} is outside {} compiled sources",
459                            contribution.source,
460                            self.source_count()
461                        ),
462                    }
463                })?;
464                let weight = contribution.intensity_weight * power;
465                if !weight.is_finite() || weight < 0.0 {
466                    return Err(Error::InvalidParameter {
467                        name: "acquisition",
468                        reason: "compiled source weights must be finite and non-negative".into(),
469                    });
470                }
471                if weight != 0.0 {
472                    row.push((contribution.source, weight));
473                }
474            }
475            if row.is_empty() {
476                return Err(Error::InvalidParameter {
477                    name: "acquisition",
478                    reason: "every calibrated frame must retain positive source weight".into(),
479                });
480            }
481            matrix.push(row);
482            gains.push(frame.gain);
483        }
484        let identity = matrix.len() == self.source_count()
485            && matrix
486                .iter()
487                .enumerate()
488                .all(|(source, row)| row.as_slice() == [(source, 1.0)]);
489        let previous_matrix =
490            std::mem::replace(&mut self.multiplexing_matrix, (!identity).then_some(matrix));
491        let previous_gains = self.frame_gains.replace(gains);
492        if let Err(error) = self.validate() {
493            self.multiplexing_matrix = previous_matrix;
494            self.frame_gains = previous_gains;
495            return Err(error);
496        }
497        Ok(())
498    }
499
500    /// Recompiles illumination-dependent geometry into the existing numerical grid.
501    ///
502    /// The low- and high-resolution shapes, optical sampling, pupil values,
503    /// background, and other static model state are retained. A geometry that
504    /// would move a crop outside the existing reconstruction grid is rejected.
505    pub fn update_illumination_geometry(
506        &mut self,
507        optics: &Optics,
508        illumination: &Illumination,
509    ) -> Result<()> {
510        let resolved = illumination.resolve(optics)?;
511        if resolved.source_count() != self.source_count()
512            || resolved.frame_count() != self.frame_count()
513        {
514            return Err(Error::InvalidModel(
515                "calibrated illumination must preserve source and frame counts".into(),
516            ));
517        }
518        let k_vectors = resolved.k_vectors().to_vec();
519        let mut offsets = Vec::with_capacity(k_vectors.len());
520        let crops = k_vectors
521            .iter()
522            .map(|vector| {
523                let (crop, offset) = Self::crop_for_k_vector(
524                    vector,
525                    &self.sampling,
526                    self.image_shape,
527                    self.reconstruction_shape,
528                )?;
529                offsets.push(offset);
530                Ok(crop)
531            })
532            .collect::<Result<Vec<_>>>()?;
533
534        let previous_vectors = std::mem::replace(&mut self.k_vectors, k_vectors);
535        let previous_crops = std::mem::replace(&mut self.crop_indices, CropIndices::new(crops));
536        let previous_offsets = self.subpixel_offsets.replace(offsets);
537        let previous_synthetic_na = self.sampling.synthetic_na;
538        self.sampling.synthetic_na = Some(
539            optics.objective_na
540                + self
541                    .k_vectors
542                    .iter()
543                    .map(|vector| {
544                        vector.kx.hypot(vector.ky) * optics.wavelength_vacuum_m
545                            / std::f64::consts::TAU
546                    })
547                    .fold(0.0, f64::max),
548        );
549        if let Err(error) =
550            self.update_intensity_calibration(resolved.source_power(), illumination.acquisition())
551        {
552            self.k_vectors = previous_vectors;
553            self.crop_indices = previous_crops;
554            self.subpixel_offsets = previous_offsets;
555            self.sampling.synthetic_na = previous_synthetic_na;
556            return Err(error);
557        }
558        self.validate()
559    }
560
561    /// Replaces the source wave vectors while preserving the compiled crops.
562    ///
563    /// This is intended for calibrated models whose replacement vectors use
564    /// the same Fourier sampling and source ordering. The full model invariant
565    /// set is revalidated before the replacement is committed.
566    pub fn replace_k_vectors(&mut self, values: Vec<KVector>) -> Result<()> {
567        let previous = std::mem::replace(&mut self.k_vectors, values);
568        if let Err(error) = self.validate() {
569            self.k_vectors = previous;
570            return Err(error);
571        }
572        Ok(())
573    }
574
575    /// Replaces optical intensity background with a broadcast frame, full stack, or `None`.
576    pub fn with_background(mut self, values: Option<Vec<f64>>) -> Result<Self> {
577        self.background = values;
578        self.validate()?;
579        Ok(self)
580    }
581
582    /// Sets compiled sparse acquisition with one non-empty non-negative row per frame.
583    pub fn with_multiplexing(mut self, matrix: MultiplexingMatrix) -> Result<Self> {
584        self.multiplexing_matrix = Some(matrix);
585        self.validate()?;
586        Ok(self)
587    }
588
589    /// Sets one finite fractional Fourier-grid offset per individual source.
590    pub fn with_subpixel_offsets(mut self, offsets: Vec<FourierOffset>) -> Result<Self> {
591        self.subpixel_offsets = Some(offsets);
592        self.validate()?;
593        Ok(self)
594    }
595
596    /// Returns a source's fractional Fourier-grid offset, or zero when absent.
597    pub fn source_offset(&self, source: usize) -> Result<FourierOffset> {
598        self.crop_indices.get(source)?;
599        match &self.subpixel_offsets {
600            None => Ok(FourierOffset::default()),
601            Some(offsets) => offsets.get(source).copied().ok_or_else(|| {
602                Error::InvalidModel("subpixel offset count does not match source count".into())
603            }),
604        }
605    }
606
607    /// Extracts one source patch from a standard-layout high-resolution object spectrum.
608    ///
609    /// `destination` has `image_shape.0 * image_shape.1` row-major complex values.
610    /// Fractional offsets use bilinear Fourier-grid interpolation.
611    pub fn extract_patch(
612        &self,
613        object_spectrum: ArrayView2<'_, Complex64>,
614        source: usize,
615        destination: &mut [Complex64],
616    ) -> Result<()> {
617        let object_spectrum = StandardView2::try_from(object_spectrum)?;
618        self.extract_patch_standard(object_spectrum, source, destination)
619    }
620
621    pub(crate) fn extract_patch_standard(
622        &self,
623        object_spectrum: StandardView2<'_, Complex64>,
624        source: usize,
625        destination: &mut [Complex64],
626    ) -> Result<()> {
627        self.extract_patch_at_offset(
628            object_spectrum,
629            source,
630            self.source_offset(source)?,
631            destination,
632        )
633    }
634
635    pub(crate) fn extract_patch_at_offset(
636        &self,
637        object_spectrum: StandardView2<'_, Complex64>,
638        source: usize,
639        offset: FourierOffset,
640        destination: &mut [Complex64],
641    ) -> Result<()> {
642        if object_spectrum.dim() != self.reconstruction_shape {
643            return Err(Error::InvalidShape(format!(
644                "object spectrum shape {:?} does not match {:?}",
645                object_spectrum.dim(),
646                self.reconstruction_shape
647            )));
648        }
649        let crop = self.crop_indices.get(source)?;
650        crop.extract_subpixel_standard(object_spectrum, destination, offset)
651    }
652
653    /// Adds an update into a high-resolution spectrum through the adjoint crop operator.
654    pub fn insert_patch_adjoint(
655        &self,
656        destination: ArrayViewMut2<'_, Complex64>,
657        source: usize,
658        update: &[Complex64],
659        scale: f64,
660    ) -> Result<()> {
661        let destination = crate::array_layout::StandardViewMut2::try_from(destination)?;
662        self.insert_patch_adjoint_standard(destination, source, update, scale)
663    }
664
665    pub(crate) fn insert_patch_adjoint_standard(
666        &self,
667        destination: crate::array_layout::StandardViewMut2<'_, Complex64>,
668        source: usize,
669        update: &[Complex64],
670        scale: f64,
671    ) -> Result<()> {
672        self.insert_patch_adjoint_at_offset(
673            destination,
674            source,
675            update,
676            scale,
677            self.source_offset(source)?,
678        )
679    }
680
681    pub(crate) fn insert_patch_adjoint_at_offset(
682        &self,
683        mut destination: crate::array_layout::StandardViewMut2<'_, Complex64>,
684        source: usize,
685        update: &[Complex64],
686        scale: f64,
687        offset: FourierOffset,
688    ) -> Result<()> {
689        if destination.dim() != self.reconstruction_shape {
690            return Err(Error::InvalidShape(format!(
691                "object spectrum shape {:?} does not match {:?}",
692                destination.dim(),
693                self.reconstruction_shape
694            )));
695        }
696        let crop = self.crop_indices.get(source)?;
697        crop.insert_subpixel_adjoint_slice(
698            destination.as_slice_mut(),
699            self.reconstruction_shape,
700            update,
701            scale,
702            offset,
703        )
704    }
705
706    pub(crate) fn insert_patch_adjoint_slice_at_offset(
707        &self,
708        destination: &mut [Complex64],
709        source: usize,
710        update: &[Complex64],
711        scale: f64,
712        offset: FourierOffset,
713    ) -> Result<()> {
714        let crop = self.crop_indices.get(source)?;
715        crop.insert_subpixel_adjoint_slice(
716            destination,
717            self.reconstruction_shape,
718            update,
719            scale,
720            offset,
721        )
722    }
723
724    /// Checks that `source` exists and the interpolation stencil for `offset` is in bounds.
725    pub fn validate_source_offset(&self, source: usize, offset: FourierOffset) -> Result<()> {
726        self.crop_indices
727            .get(source)?
728            .validate_subpixel_inside(self.reconstruction_shape, offset)
729    }
730
731    pub(crate) fn crop_for_k_vector(
732        vector: &KVector,
733        sampling: &Sampling,
734        image_shape: (usize, usize),
735        reconstruction_shape: (usize, usize),
736    ) -> Result<(FourierCrop, FourierOffset)> {
737        let continuous_row = vector.ky / sampling.dky;
738        let continuous_column = vector.kx / sampling.dkx;
739        let row_shift = checked_grid_shift(continuous_row)?;
740        let column_shift = checked_grid_shift(continuous_column)?;
741        let reconstruction_center_row = isize::try_from(reconstruction_shape.0 / 2)
742            .map_err(|_| Error::InvalidShape("reconstruction height is too large".into()))?;
743        let reconstruction_center_column = isize::try_from(reconstruction_shape.1 / 2)
744            .map_err(|_| Error::InvalidShape("reconstruction width is too large".into()))?;
745        let image_half_height = isize::try_from(image_shape.0 / 2)
746            .map_err(|_| Error::InvalidShape("image height is too large".into()))?;
747        let image_half_width = isize::try_from(image_shape.1 / 2)
748            .map_err(|_| Error::InvalidShape("image width is too large".into()))?;
749        let start_row = reconstruction_center_row
750            .checked_sub(image_half_height)
751            .and_then(|value| value.checked_add(row_shift.integer));
752        let start_column = reconstruction_center_column
753            .checked_sub(image_half_width)
754            .and_then(|value| value.checked_add(column_shift.integer));
755        let (Some(start_row), Some(start_column)) = (start_row, start_column) else {
756            return Err(Error::InvalidModel(format!(
757                "illumination vector {vector:?} overflows the reconstruction grid"
758            )));
759        };
760        if start_row < 0 || start_column < 0 {
761            return Err(Error::InvalidModel(format!(
762                "illumination vector {vector:?} produces a crop outside the reconstruction grid"
763            )));
764        }
765        let crop = FourierCrop::new(
766            usize::try_from(start_row).map_err(|_| {
767                Error::InvalidModel("crop row cannot be represented as an index".into())
768            })?,
769            usize::try_from(start_column).map_err(|_| {
770                Error::InvalidModel("crop column cannot be represented as an index".into())
771            })?,
772            image_shape.0,
773            image_shape.1,
774        );
775        let offset = FourierOffset::new(row_shift.offset, column_shift.offset);
776        crop.validate_subpixel_inside(reconstruction_shape, offset)?;
777        Ok((crop, offset))
778    }
779
780    /// Returns a frame's positive gain, defaulting to `1.0` when gains are absent.
781    pub fn frame_gain(&self, frame: usize) -> Result<f64> {
782        if frame >= self.frame_count() {
783            return Err(Error::FrameOutOfRange {
784                index: frame,
785                frames: self.frame_count(),
786            });
787        }
788        Ok(self
789            .frame_gains
790            .as_ref()
791            .map_or(1.0, |values| values[frame]))
792    }
793
794    /// Returns background intensity for a frame and row-major pixel index.
795    ///
796    /// Returns zero when background is absent and handles broadcast backgrounds.
797    pub fn background_value(&self, frame: usize, pixel: usize) -> Result<f64> {
798        if frame >= self.frame_count() {
799            return Err(Error::FrameOutOfRange {
800                index: frame,
801                frames: self.frame_count(),
802            });
803        }
804        let image_len = checked_len_2d(self.image_shape)?;
805        if pixel >= image_len {
806            return Err(Error::InvalidParameter {
807                name: "pixel",
808                reason: format!("index {pixel} is outside an image with {image_len} pixels"),
809            });
810        }
811        Ok(self.background.as_ref().map_or(0.0, |values| {
812            values[if values.len() == image_len {
813                pixel
814            } else {
815                frame * image_len + pixel
816            }]
817        }))
818    }
819
820    /// Validates source/crop counts, shapes, pupil, sampling, vectors, interpolation
821    /// bounds, gains, background, and optional multiplexing rows.
822    pub fn validate(&self) -> Result<()> {
823        self.sampling.validate()?;
824        if self.k_vectors.is_empty() || self.source_count() != self.crop_indices.len() {
825            return Err(Error::InvalidModel(format!(
826                "{} source k-vectors and {} crops; counts must be equal and non-zero",
827                self.source_count(),
828                self.crop_indices.len()
829            )));
830        }
831        if self
832            .k_vectors
833            .iter()
834            .any(|vector| !vector.kx.is_finite() || !vector.ky.is_finite())
835        {
836            return Err(Error::InvalidModel(
837                "k-vectors must contain finite values".into(),
838            ));
839        }
840        if self.pupil.shape() != self.image_shape {
841            return Err(Error::InvalidModel(format!(
842                "pupil shape {:?} does not match image shape {:?}",
843                self.pupil.shape(),
844                self.image_shape
845            )));
846        }
847        if self.pupil.support.len() != self.pupil.values.len()
848            || self
849                .pupil
850                .values
851                .as_slice()
852                .iter()
853                .any(|value| !value.re.is_finite() || !value.im.is_finite())
854        {
855            return Err(Error::InvalidModel(
856                "pupil support or numeric values are invalid".into(),
857            ));
858        }
859        if self.reconstruction_shape.0 < self.image_shape.0
860            || self.reconstruction_shape.1 < self.image_shape.1
861        {
862            return Err(Error::InvalidModel(
863                "reconstruction shape must contain a low-resolution crop".into(),
864            ));
865        }
866        for crop in &self.crop_indices.crops {
867            if (crop.height, crop.width) != self.image_shape {
868                return Err(Error::InvalidModel(format!(
869                    "crop shape {:?} does not match image shape {:?}",
870                    (crop.height, crop.width),
871                    self.image_shape
872                )));
873            }
874            crop.validate_inside(self.reconstruction_shape)?;
875        }
876        if let Some(offsets) = &self.subpixel_offsets {
877            if offsets.len() != self.source_count() {
878                return Err(Error::InvalidModel(
879                    "subpixel offset count does not match source count".into(),
880                ));
881            }
882            for (crop, &offset) in self.crop_indices.crops.iter().zip(offsets) {
883                crop.validate_subpixel_inside(self.reconstruction_shape, offset)?;
884            }
885        }
886        if let Some(values) = &self.frame_gains {
887            if values.len() != self.frame_count() {
888                return Err(Error::InvalidModel(
889                    "frame gain count does not match frame count".into(),
890                ));
891            }
892            if values
893                .iter()
894                .any(|value| !value.is_finite() || *value < 0.0)
895            {
896                return Err(Error::InvalidModel(
897                    "frame gains must be finite and non-negative".into(),
898                ));
899            }
900        }
901        let image_len = checked_len_2d(self.image_shape)?;
902        let stack_len =
903            image_len
904                .checked_mul(self.frame_count())
905                .ok_or_else(|| Error::ShapeOverflow {
906                    shape: vec![self.frame_count(), self.image_shape.0, self.image_shape.1],
907                })?;
908        if self
909            .background
910            .as_ref()
911            .is_some_and(|values| values.len() != image_len && values.len() != stack_len)
912        {
913            return Err(Error::InvalidModel(
914                "background must be one image or one image per frame".into(),
915            ));
916        }
917        if self
918            .background
919            .as_ref()
920            .is_some_and(|values| values.iter().any(|value| !value.is_finite()))
921        {
922            return Err(Error::InvalidModel(
923                "background values must be finite".into(),
924            ));
925        }
926        if let Some(matrix) = &self.multiplexing_matrix {
927            if matrix.is_empty() {
928                return Err(Error::InvalidModel(
929                    "multiplexing matrix must contain at least one measured frame".into(),
930                ));
931            }
932            for (row_index, row) in matrix.iter().enumerate() {
933                let mut seen = vec![false; self.source_count()];
934                let invalid = row.is_empty()
935                    || row.iter().any(|&(source, weight)| {
936                        let duplicate = source < self.source_count() && seen[source];
937                        if source < self.source_count() {
938                            seen[source] = true;
939                        }
940                        source >= self.source_count()
941                            || !weight.is_finite()
942                            || weight < 0.0
943                            || duplicate
944                    });
945                if invalid {
946                    return Err(Error::InvalidModel(format!(
947                        "multiplexing row {row_index} is empty or contains a duplicate/invalid source or weight"
948                    )));
949                }
950            }
951        }
952        Ok(())
953    }
954}
955
956fn checked_grid_shift(value: f64) -> Result<GridShift> {
957    if !value.is_finite() {
958        return Err(Error::InvalidModel(
959            "illumination shift must be finite".into(),
960        ));
961    }
962    let rounded = value.round();
963    if rounded < isize::MIN as f64 || rounded > isize::MAX as f64 {
964        return Err(Error::InvalidModel(
965            "illumination shift is outside the supported index range".into(),
966        ));
967    }
968    let integer = rounded as isize;
969    let offset = value - rounded;
970    let nearest_offset = offset.round();
971    let (lower, upper) = if (offset - nearest_offset).abs() <= 1e-12 {
972        let displacement = integer
973            .checked_add(nearest_offset as isize)
974            .ok_or_else(|| {
975                Error::InvalidModel(
976                    "illumination shift is outside the supported index range".into(),
977                )
978            })?;
979        (displacement, displacement)
980    } else if offset > 0.0 {
981        (
982            integer,
983            integer.checked_add(1).ok_or_else(|| {
984                Error::InvalidModel(
985                    "illumination shift is outside the supported index range".into(),
986                )
987            })?,
988        )
989    } else {
990        (
991            integer.checked_sub(1).ok_or_else(|| {
992                Error::InvalidModel(
993                    "illumination shift is outside the supported index range".into(),
994                )
995            })?,
996            integer,
997        )
998    };
999    Ok(GridShift {
1000        integer,
1001        offset,
1002        lower,
1003        upper,
1004    })
1005}
1006
1007fn validate_image_shape(image_shape: (usize, usize)) -> Result<()> {
1008    if image_shape.0 == 0 || image_shape.1 == 0 {
1009        return Err(Error::InvalidShape(format!(
1010            "image shape {image_shape:?} must be non-zero"
1011        )));
1012    }
1013    if image_shape.0 > isize::MAX as usize || image_shape.1 > isize::MAX as usize {
1014        return Err(Error::InvalidShape(
1015            "image shape is outside the supported index range".into(),
1016        ));
1017    }
1018    Ok(())
1019}
1020
1021fn validate_reconstruction_aspect(
1022    image_shape: (usize, usize),
1023    reconstruction_shape: (usize, usize),
1024) -> Result<()> {
1025    if reconstruction_shape.0 < image_shape.0 || reconstruction_shape.1 < image_shape.1 {
1026        return Err(Error::InvalidShape(format!(
1027            "image shape {image_shape:?} must fit reconstruction shape {reconstruction_shape:?}"
1028        )));
1029    }
1030    if reconstruction_shape.0 > isize::MAX as usize || reconstruction_shape.1 > isize::MAX as usize
1031    {
1032        return Err(Error::InvalidShape(
1033            "reconstruction shape is outside the supported index range".into(),
1034        ));
1035    }
1036    let left = reconstruction_shape.0 as u128 * image_shape.1 as u128;
1037    let right = reconstruction_shape.1 as u128 * image_shape.0 as u128;
1038    if left != right {
1039        return Err(Error::InvalidShape(
1040            "reconstruction must use the same scale factor in both dimensions".into(),
1041        ));
1042    }
1043    Ok(())
1044}
1045
1046fn shape_contains_bounds(
1047    image_shape: (usize, usize),
1048    reconstruction_shape: (usize, usize),
1049    bounds: &[CropDisplacementBounds],
1050) -> Result<bool> {
1051    validate_reconstruction_aspect(image_shape, reconstruction_shape)?;
1052    for bound in bounds {
1053        if !axis_contains_bounds(
1054            image_shape.0,
1055            reconstruction_shape.0,
1056            bound.minimum_row,
1057            bound.maximum_row,
1058        )? || !axis_contains_bounds(
1059            image_shape.1,
1060            reconstruction_shape.1,
1061            bound.minimum_column,
1062            bound.maximum_column,
1063        )? {
1064            return Ok(false);
1065        }
1066    }
1067    Ok(true)
1068}
1069
1070fn axis_contains_bounds(
1071    image_length: usize,
1072    reconstruction_length: usize,
1073    minimum_displacement: isize,
1074    maximum_displacement: isize,
1075) -> Result<bool> {
1076    let centre = i128::try_from(reconstruction_length / 2)
1077        .map_err(|_| Error::InvalidShape("reconstruction dimension is too large".into()))?;
1078    let image_half = i128::try_from(image_length / 2)
1079        .map_err(|_| Error::InvalidShape("image dimension is too large".into()))?;
1080    let base = centre.checked_sub(image_half).ok_or_else(|| {
1081        Error::InvalidShape("reconstruction crop origin overflows the index range".into())
1082    })?;
1083    let lower = base
1084        .checked_add(minimum_displacement as i128)
1085        .ok_or_else(|| {
1086            Error::InvalidShape("reconstruction crop origin overflows the index range".into())
1087        })?;
1088    let upper = base
1089        .checked_add((image_length - 1) as i128)
1090        .and_then(|value| value.checked_add(maximum_displacement as i128))
1091        .ok_or_else(|| {
1092            Error::InvalidShape("reconstruction crop extent overflows the index range".into())
1093        })?;
1094    Ok(lower >= 0 && upper < reconstruction_length as i128)
1095}
1096
1097fn minimum_fitting_multiplier(
1098    image_shape: (usize, usize),
1099    aspect: (usize, usize),
1100    initial_multiplier: usize,
1101    maximum_multiplier: usize,
1102    bounds: &[CropDisplacementBounds],
1103) -> Result<usize> {
1104    let fits = |multiplier| -> Result<bool> {
1105        let shape = candidate_shape(aspect, multiplier)?;
1106        shape_contains_bounds(image_shape, shape, bounds)
1107    };
1108    if fits(initial_multiplier)? {
1109        return Ok(initial_multiplier);
1110    }
1111    let mut lower = initial_multiplier;
1112    let mut upper = initial_multiplier;
1113    loop {
1114        let doubled = upper.checked_mul(2).unwrap_or(maximum_multiplier);
1115        upper = doubled.min(maximum_multiplier);
1116        if upper == lower {
1117            return Err(Error::InvalidShape(
1118                "illumination crops require a reconstruction shape outside the supported index range"
1119                    .into(),
1120            ));
1121        }
1122        if fits(upper)? {
1123            break;
1124        }
1125        lower = upper;
1126    }
1127    while lower + 1 < upper {
1128        let middle = lower + (upper - lower) / 2;
1129        if fits(middle)? {
1130            upper = middle;
1131        } else {
1132            lower = middle;
1133        }
1134    }
1135    Ok(upper)
1136}
1137
1138fn candidate_shape(aspect: (usize, usize), multiplier: usize) -> Result<(usize, usize)> {
1139    let height = aspect.0.checked_mul(multiplier).ok_or_else(|| {
1140        Error::InvalidShape("reconstruction height overflows the supported range".into())
1141    })?;
1142    let width = aspect.1.checked_mul(multiplier).ok_or_else(|| {
1143        Error::InvalidShape("reconstruction width overflows the supported range".into())
1144    })?;
1145    Ok((height, width))
1146}
1147
1148fn greatest_common_divisor(mut left: usize, mut right: usize) -> usize {
1149    while right != 0 {
1150        let remainder = left % right;
1151        left = right;
1152        right = remainder;
1153    }
1154    left
1155}
1156
1157fn next_smooth_multiplier(minimum: usize) -> Option<usize> {
1158    fn visit(value: usize, prime_index: usize, minimum: usize, best: &mut Option<usize>) {
1159        if value >= minimum {
1160            if best.is_none_or(|current| value < current) {
1161                *best = Some(value);
1162            }
1163            return;
1164        }
1165        const PRIMES: [usize; 4] = [2, 3, 5, 7];
1166        for (index, &prime) in PRIMES.iter().enumerate().skip(prime_index) {
1167            let Some(next) = value.checked_mul(prime) else {
1168                continue;
1169            };
1170            if best.is_none_or(|current| next < current) {
1171                visit(next, index, minimum, best);
1172            }
1173        }
1174    }
1175
1176    let mut best = None;
1177    visit(1, 0, minimum, &mut best);
1178    best
1179}