Skip to main content

fpm_rs/
configuration.rs

1//! Versioned experiment and simulation configuration.
2//!
3//! The schema stores both concrete experiment descriptions and their compiled
4//! models. Loading validates the descriptions, compiled models, dimensions,
5//! source geometry, camera, and acquisition effects before returning data to a
6//! caller.
7
8use std::{
9    fs::File,
10    io::{BufReader, BufWriter},
11    path::Path,
12};
13
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    Result,
18    array_layout::checked_len_2d,
19    error::Error,
20    experiment::{Illumination, Optics, ResolvedIllumination},
21    model::{ImagePlaneModel, ReconstructionShape},
22    simulation::{CameraModel, IlluminationAcquisitionErrors},
23};
24
25/// Current serialized experiment/simulation configuration format.
26pub const CONFIGURATION_FORMAT_VERSION: u32 = 2;
27
28/// Concrete optical experiment description used to compile an image-plane model.
29#[derive(Clone, Debug, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ExperimentDescription {
32    /// Physical microscope parameters in SI units.
33    pub optics: Optics,
34    /// Concrete source geometry or calibrated illumination description.
35    pub illumination: Illumination,
36    /// Optional optical intensity background, broadcast or stored per frame.
37    pub optical_background: Option<Vec<f64>>,
38}
39
40impl ExperimentDescription {
41    /// Creates an experiment without optical intensity background.
42    pub fn new(optics: Optics, illumination: Illumination) -> Self {
43        Self {
44            optics,
45            illumination,
46            optical_background: None,
47        }
48    }
49
50    /// Sets non-negative optical intensity background values.
51    ///
52    /// During compilation the vector must contain either one row-major low-resolution
53    /// frame or a full `(frame, row, column)` acquisition stack.
54    pub fn with_optical_background(mut self, background: Vec<f64>) -> Self {
55        self.optical_background = Some(background);
56        self
57    }
58
59    /// Compiles optics and illumination into an owned [`ImagePlaneModel`].
60    pub fn compile(
61        &self,
62        image_shape: (usize, usize),
63        reconstruction_shape: ReconstructionShape,
64    ) -> Result<ImagePlaneModel> {
65        let resolved = self.illumination.resolve(&self.optics)?;
66        self.compile_resolved(image_shape, reconstruction_shape, &resolved)
67    }
68
69    fn compile_resolved(
70        &self,
71        image_shape: (usize, usize),
72        reconstruction_shape: ReconstructionShape,
73        resolved: &ResolvedIllumination,
74    ) -> Result<ImagePlaneModel> {
75        let mut model =
76            ImagePlaneModel::compile(&self.optics, resolved, image_shape, reconstruction_shape)?;
77        model.background = self.optical_background.clone();
78        model.validate()?;
79        Ok(model)
80    }
81
82    /// Validates optics, source compilation, gains, multiplexing, and finite non-negative
83    /// optical background values.
84    pub fn validate(&self) -> Result<()> {
85        self.optics.validate()?;
86        self.illumination.resolve(&self.optics)?;
87        if self.optical_background.as_ref().is_some_and(|values| {
88            values
89                .iter()
90                .any(|value| !value.is_finite() || *value < 0.0)
91        }) {
92            return Err(Error::InvalidParameter {
93                name: "optical_background",
94                reason: "values must be finite and non-negative".into(),
95            });
96        }
97        Ok(())
98    }
99}
100
101/// True and assumed reconstruction models compiled from concrete descriptions.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct CompiledModelPair {
105    /// Model representing the physical acquisition used for simulation or evaluation.
106    pub true_model: ImagePlaneModel,
107    /// Model assumed by reconstruction, which may encode a deliberate mismatch.
108    pub reconstruction_model: ImagePlaneModel,
109}
110
111impl CompiledModelPair {
112    /// Validates both models and requires equal image/object shapes and frame counts.
113    pub fn validate(&self) -> Result<()> {
114        self.true_model.validate()?;
115        self.reconstruction_model.validate()?;
116        if self.true_model.image_shape != self.reconstruction_model.image_shape
117            || self.true_model.reconstruction_shape
118                != self.reconstruction_model.reconstruction_shape
119            || self.true_model.frame_count() != self.reconstruction_model.frame_count()
120        {
121            return Err(Error::InvalidModel(
122                "compiled true and reconstruction models must have matching image, object, and frame dimensions"
123                    .into(),
124            ));
125        }
126        Ok(())
127    }
128}
129
130/// Complete, versioned configuration for simulation and reconstruction.
131#[derive(Clone, Debug, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct SimulationConfiguration {
134    /// Must equal [`CONFIGURATION_FORMAT_VERSION`].
135    pub format_version: u32,
136    /// Physical description used to compile the true acquisition model.
137    pub true_experiment: ExperimentDescription,
138    /// Physical description assumed by reconstruction.
139    pub reconstruction_experiment: ExperimentDescription,
140    /// Shared low-resolution shape as `(height, width)`.
141    pub image_shape: (usize, usize),
142    /// Shared high-resolution shape as `(height, width)`.
143    pub reconstruction_shape: (usize, usize),
144    /// Stored true and reconstruction models compiled from the descriptions.
145    pub compiled_models: CompiledModelPair,
146    /// Optional detector-count response and noise model.
147    pub camera: Option<CameraModel>,
148    /// Optional non-geometric illumination errors injected during acquisition.
149    pub illumination_acquisition_errors: Option<IlluminationAcquisitionErrors>,
150    /// Deterministic seed for simulation randomness.
151    pub random_seed: u64,
152}
153
154impl SimulationConfiguration {
155    /// Compiles true and assumed descriptions on one explicit or automatically selected
156    /// reconstruction grid and validates the complete configuration.
157    pub fn new(
158        true_experiment: ExperimentDescription,
159        reconstruction_experiment: ExperimentDescription,
160        image_shape: (usize, usize),
161        reconstruction_shape: ReconstructionShape,
162    ) -> Result<Self> {
163        let true_resolved = true_experiment
164            .illumination
165            .resolve(&true_experiment.optics)?;
166        let reconstruction_resolved = reconstruction_experiment
167            .illumination
168            .resolve(&reconstruction_experiment.optics)?;
169        let true_bounds = ImagePlaneModel::crop_displacement_bounds(
170            &true_experiment.optics,
171            image_shape,
172            true_resolved.k_vectors(),
173        )?;
174        let reconstruction_bounds = ImagePlaneModel::crop_displacement_bounds(
175            &reconstruction_experiment.optics,
176            image_shape,
177            reconstruction_resolved.k_vectors(),
178        )?;
179        let reconstruction_shape = ImagePlaneModel::resolve_reconstruction_shape(
180            image_shape,
181            reconstruction_shape,
182            &[true_bounds, reconstruction_bounds],
183        )?;
184        let compiled_models = CompiledModelPair {
185            true_model: true_experiment.compile_resolved(
186                image_shape,
187                ReconstructionShape::Exact(reconstruction_shape),
188                &true_resolved,
189            )?,
190            reconstruction_model: reconstruction_experiment.compile_resolved(
191                image_shape,
192                ReconstructionShape::Exact(reconstruction_shape),
193                &reconstruction_resolved,
194            )?,
195        };
196        let configuration = Self {
197            format_version: CONFIGURATION_FORMAT_VERSION,
198            true_experiment,
199            reconstruction_experiment,
200            image_shape,
201            reconstruction_shape,
202            compiled_models,
203            camera: None,
204            illumination_acquisition_errors: None,
205            random_seed: 0,
206        };
207        configuration.validate()?;
208        Ok(configuration)
209    }
210
211    /// Adds a camera model after validating it for the configured frame shape.
212    pub fn with_camera(mut self, camera: CameraModel) -> Result<Self> {
213        self.camera = Some(camera);
214        self.validate()?;
215        Ok(self)
216    }
217
218    /// Adds source gain variation, missing frames, or a source permutation and validates it.
219    pub fn with_illumination_acquisition_errors(
220        mut self,
221        errors: IlluminationAcquisitionErrors,
222    ) -> Result<Self> {
223        self.illumination_acquisition_errors = Some(errors);
224        self.validate()?;
225        Ok(self)
226    }
227
228    /// Sets the deterministic random seed used for simulation.
229    pub fn with_random_seed(mut self, random_seed: u64) -> Self {
230        self.random_seed = random_seed;
231        self
232    }
233
234    /// Returns the assumed model with known linear camera response compiled in.
235    ///
236    /// The serialized `compiled_models.reconstruction_model` remains the
237    /// strict optical model. Use this helper when constructing a
238    /// reconstruction problem from detector counts loaded through a saved
239    /// configuration.
240    pub fn reconstruction_model_for_counts(&self) -> Result<ImagePlaneModel> {
241        match &self.camera {
242            Some(camera) => camera
243                .compile_reconstruction_model(self.compiled_models.reconstruction_model.clone()),
244            None => Ok(self.compiled_models.reconstruction_model.clone()),
245        }
246    }
247
248    /// Checks format version; descriptions and compiled models; shape/frame agreement;
249    /// camera constraints; and acquisition-error counts, indices, and permutations.
250    pub fn validate(&self) -> Result<()> {
251        if self.format_version != CONFIGURATION_FORMAT_VERSION {
252            return Err(Error::InvalidParameter {
253                name: "configuration format_version",
254                reason: format!(
255                    "expected {CONFIGURATION_FORMAT_VERSION}, got {}",
256                    self.format_version
257                ),
258            });
259        }
260        self.true_experiment.validate()?;
261        self.reconstruction_experiment.validate()?;
262        self.compiled_models.validate()?;
263        validate_compiled_description(
264            &self.true_experiment,
265            &self.compiled_models.true_model,
266            self.image_shape,
267            self.reconstruction_shape,
268        )?;
269        validate_compiled_description(
270            &self.reconstruction_experiment,
271            &self.compiled_models.reconstruction_model,
272            self.image_shape,
273            self.reconstruction_shape,
274        )?;
275        if let Some(camera) = &self.camera {
276            camera.validate_for_frame(checked_len_2d(self.image_shape)?)?;
277        }
278        if let Some(errors) = &self.illumination_acquisition_errors {
279            validate_acquisition_errors(errors, &self.compiled_models.true_model)?;
280        }
281        Ok(())
282    }
283
284    /// Validates and writes this versioned configuration as pretty-printed JSON.
285    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
286        self.validate()?;
287        let writer = BufWriter::new(File::create(path)?);
288        serde_json::to_writer_pretty(writer, self)?;
289        Ok(())
290    }
291
292    /// Loads and fully validates a versioned JSON configuration.
293    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
294        let reader = BufReader::new(File::open(path)?);
295        let configuration: Self = serde_json::from_reader(reader)?;
296        configuration.validate()?;
297        Ok(configuration)
298    }
299}
300
301fn validate_compiled_description(
302    description: &ExperimentDescription,
303    stored: &ImagePlaneModel,
304    image_shape: (usize, usize),
305    reconstruction_shape: (usize, usize),
306) -> Result<()> {
307    if stored.image_shape != image_shape || stored.reconstruction_shape != reconstruction_shape {
308        return Err(Error::InvalidModel(
309            "compiled model dimensions differ from configuration dimensions".into(),
310        ));
311    }
312    let expected = description.compile(
313        image_shape,
314        ReconstructionShape::Exact(reconstruction_shape),
315    )?;
316    if expected.frame_count() != stored.frame_count()
317        || expected.source_count() != stored.source_count()
318        || expected.crop_indices.crops != stored.crop_indices.crops
319        || expected.pupil.support != stored.pupil.support
320        || !complex_values_close(
321            expected.pupil.values.as_slice(),
322            stored.pupil.values.as_slice(),
323        )
324        || !vectors_close(&expected.k_vectors, &stored.k_vectors)
325        || !optional_values_close(
326            expected.frame_gains.as_deref(),
327            stored.frame_gains.as_deref(),
328        )
329        || !optional_values_close(expected.background.as_deref(), stored.background.as_deref())
330        || !multiplexing_close(
331            expected.multiplexing_matrix.as_deref(),
332            stored.multiplexing_matrix.as_deref(),
333        )
334        || !sampling_close(&expected.sampling, &stored.sampling)
335        || !offsets_close(
336            expected.subpixel_offsets.as_deref(),
337            stored.subpixel_offsets.as_deref(),
338        )
339    {
340        return Err(Error::InvalidModel(
341            "compiled model is inconsistent with its experiment description".into(),
342        ));
343    }
344    Ok(())
345}
346
347fn complex_values_close(expected: &[crate::Complex64], actual: &[crate::Complex64]) -> bool {
348    expected.len() == actual.len()
349        && expected.iter().zip(actual).all(|(expected, actual)| {
350            close(expected.re, actual.re) && close(expected.im, actual.im)
351        })
352}
353
354fn optional_values_close(expected: Option<&[f64]>, actual: Option<&[f64]>) -> bool {
355    match (expected, actual) {
356        (None, None) => true,
357        (Some(expected), Some(actual)) => {
358            expected.len() == actual.len()
359                && expected
360                    .iter()
361                    .zip(actual)
362                    .all(|(&expected, &actual)| close(expected, actual))
363        }
364        _ => false,
365    }
366}
367
368fn multiplexing_close(
369    expected: Option<&[Vec<crate::experiment::SourceWeight>]>,
370    actual: Option<&[Vec<crate::experiment::SourceWeight>]>,
371) -> bool {
372    match (expected, actual) {
373        (None, None) => true,
374        (Some(expected), Some(actual)) => {
375            expected.len() == actual.len()
376                && expected.iter().zip(actual).all(|(expected, actual)| {
377                    expected.len() == actual.len()
378                        && expected.iter().zip(actual).all(
379                            |(
380                                &(expected_source, expected_weight),
381                                &(actual_source, actual_weight),
382                            )| {
383                                expected_source == actual_source
384                                    && close(expected_weight, actual_weight)
385                            },
386                        )
387                })
388        }
389        _ => false,
390    }
391}
392
393fn sampling_close(expected: &crate::model::Sampling, actual: &crate::model::Sampling) -> bool {
394    expected.coordinate_convention == actual.coordinate_convention
395        && close(expected.low_res_pixel_size, actual.low_res_pixel_size)
396        && close(expected.high_res_pixel_size, actual.high_res_pixel_size)
397        && close(expected.dkx, actual.dkx)
398        && close(expected.dky, actual.dky)
399        && optional_scalar_close(expected.wavelength, actual.wavelength)
400        && optional_scalar_close(expected.synthetic_na, actual.synthetic_na)
401}
402
403fn optional_scalar_close(expected: Option<f64>, actual: Option<f64>) -> bool {
404    match (expected, actual) {
405        (None, None) => true,
406        (Some(expected), Some(actual)) => close(expected, actual),
407        _ => false,
408    }
409}
410
411fn vectors_close(
412    expected: &[crate::experiment::KVector],
413    actual: &[crate::experiment::KVector],
414) -> bool {
415    expected.len() == actual.len()
416        && expected.iter().zip(actual).all(|(expected, actual)| {
417            close(expected.kx, actual.kx) && close(expected.ky, actual.ky)
418        })
419}
420
421fn offsets_close(
422    expected: Option<&[crate::model::FourierOffset]>,
423    actual: Option<&[crate::model::FourierOffset]>,
424) -> bool {
425    match (expected, actual) {
426        (None, None) => true,
427        (Some(expected), Some(actual)) => {
428            expected.len() == actual.len()
429                && expected.iter().zip(actual).all(|(expected, actual)| {
430                    close(expected.row, actual.row) && close(expected.column, actual.column)
431                })
432        }
433        _ => false,
434    }
435}
436
437fn close(expected: f64, actual: f64) -> bool {
438    (expected - actual).abs() <= 1e-12 * expected.abs().max(actual.abs()).max(1.0)
439}
440
441fn validate_acquisition_errors(
442    errors: &IlluminationAcquisitionErrors,
443    model: &ImagePlaneModel,
444) -> Result<()> {
445    if !errors.frame_gain_relative_std.is_finite() || errors.frame_gain_relative_std < 0.0 {
446        return Err(Error::InvalidParameter {
447            name: "frame_gain_relative_std",
448            reason: "must be finite and non-negative".into(),
449        });
450    }
451    let mut missing = errors.missing_frames.clone();
452    missing.sort_unstable();
453    if missing.iter().any(|&frame| frame >= model.frame_count())
454        || missing.windows(2).any(|pair| pair[0] == pair[1])
455    {
456        return Err(Error::InvalidParameter {
457            name: "missing_frames",
458            reason: format!(
459                "must contain unique frame indices below {}",
460                model.frame_count()
461            ),
462        });
463    }
464    if let Some(permutation) = &errors.source_permutation {
465        let mut sorted = permutation.clone();
466        sorted.sort_unstable();
467        if permutation.len() != model.source_count()
468            || sorted
469                .iter()
470                .enumerate()
471                .any(|(expected, &actual)| expected != actual)
472        {
473            return Err(Error::InvalidParameter {
474                name: "source_permutation",
475                reason: format!("must be a permutation of 0..{}", model.source_count()),
476            });
477        }
478    }
479    Ok(())
480}