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