diff --git a/metabodecon/src/deconvolution.rs b/metabodecon/src/deconvolution.rs index 069d486..82d894d 100644 --- a/metabodecon/src/deconvolution.rs +++ b/metabodecon/src/deconvolution.rs @@ -9,6 +9,11 @@ pub use deconvolution::Deconvolution; mod lorentzian; pub use lorentzian::Lorentzian; +#[cfg(feature = "serde")] +mod serialized_representations; +#[cfg(feature = "serde")] +pub(crate) use serialized_representations::{SerializedDeconvolution, SerializedLorentzian}; + mod fitting; pub use fitting::FittingSettings; diff --git a/metabodecon/src/deconvolution/deconvolution.rs b/metabodecon/src/deconvolution/deconvolution.rs index db52931..fb51c75 100644 --- a/metabodecon/src/deconvolution/deconvolution.rs +++ b/metabodecon/src/deconvolution/deconvolution.rs @@ -4,8 +4,18 @@ use crate::deconvolution::peak_selection::SelectionSettings; use crate::deconvolution::smoothing::SmoothingSettings; use std::sync::Arc; +#[cfg(feature = "serde")] +use crate::deconvolution::SerializedDeconvolution; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Data structure representing the result of a deconvolution. #[derive(Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(into = "SerializedDeconvolution", try_from = "SerializedDeconvolution") +)] pub struct Deconvolution { /// The deconvoluted signals. lorentzians: Arc<[Lorentzian]>, @@ -19,6 +29,12 @@ pub struct Deconvolution { mse: f64, } +impl AsRef for Deconvolution { + fn as_ref(&self) -> &Deconvolution { + self + } +} + impl Deconvolution { /// Constructs a new `Deconvolution`. pub fn new( @@ -68,11 +84,66 @@ impl Deconvolution { #[cfg(test)] mod tests { use super::*; + use crate::deconvolution::ScoringMethod; use crate::{assert_send, assert_sync}; + use float_cmp::assert_approx_eq; #[test] fn thread_safety() { assert_send!(Deconvolution); assert_sync!(Deconvolution); } + + #[cfg(feature = "serde")] + #[test] + fn serialization_round_trip() { + let lorentzians = vec![ + Lorentzian::new(5.5, 0.25, 3.0), + Lorentzian::new(7.0, 0.16, 5.0), + Lorentzian::new(5.5, 0.25, 7.0), + ]; + let deconvolution = Deconvolution::new( + lorentzians.clone(), + SmoothingSettings::default(), + SelectionSettings::default(), + FittingSettings::default(), + 0.5, + ); + let serialized = serde_json::to_string(&deconvolution).unwrap(); + let deserialized = serde_json::from_str::(&serialized).unwrap(); + deconvolution + .lorentzians + .iter() + .zip(deserialized.lorentzians()) + .for_each(|(init, rec)| { + assert_approx_eq!(f64, init.sfhw(), rec.sfhw()); + assert_approx_eq!(f64, init.hw2(), rec.hw2()); + assert_approx_eq!(f64, init.maxp(), rec.maxp()); + }); + match deserialized.smoothing_settings() { + SmoothingSettings::MovingAverage { + iterations, + window_size, + } => { + assert_eq!(iterations, 2); + assert_eq!(window_size, 5); + } + }; + match deserialized.selection_settings() { + SelectionSettings::NoiseScoreFilter { + scoring_method, + threshold, + } => { + match scoring_method { + ScoringMethod::MinimumSum => {} + } + assert_approx_eq!(f64, threshold, 6.4); + } + }; + match deserialized.fitting_settings() { + FittingSettings::Analytical { iterations } => { + assert_eq!(iterations, 10); + } + }; + } } diff --git a/metabodecon/src/deconvolution/fitting/fitter.rs b/metabodecon/src/deconvolution/fitting/fitter.rs index 69c4345..5fd0bec 100644 --- a/metabodecon/src/deconvolution/fitting/fitter.rs +++ b/metabodecon/src/deconvolution/fitting/fitter.rs @@ -4,6 +4,9 @@ use crate::deconvolution::lorentzian::Lorentzian; use crate::deconvolution::peak_selection::Peak; use crate::spectrum::Spectrum; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Trait interface for fitting algorithms. pub(crate) trait Fitter { /// Fits Lorentzian functions to a spectrum using the given peaks. @@ -18,6 +21,11 @@ pub(crate) trait Fitter { /// Fitting methods. #[non_exhaustive] #[derive(Copy, Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(tag = "method", rename_all_fields = "camelCase") +)] pub enum FittingSettings { /// Fitting by solving a system of linear equations analytically. Analytical { diff --git a/metabodecon/src/deconvolution/lorentzian.rs b/metabodecon/src/deconvolution/lorentzian.rs index 08c219f..126c4be 100644 --- a/metabodecon/src/deconvolution/lorentzian.rs +++ b/metabodecon/src/deconvolution/lorentzian.rs @@ -1,6 +1,11 @@ #[cfg(feature = "parallel")] use rayon::prelude::*; +#[cfg(feature = "serde")] +use crate::deconvolution::SerializedLorentzian; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Data structure that represents a [Lorentzian function]. /// /// # Definition @@ -42,20 +47,30 @@ use rayon::prelude::*; /// /// [Lorentzian function]: https://en.wikipedia.org/wiki/Cauchy_distribution /// -/// # Negative Scale Factors and Half-Widths +/// # Negative Transformed Parameters /// -/// In order for `Lorentzian` to represent a peak shape, the scale factor `sf` -/// and half-width `hw` must be positive. This is not an invariant, though, as -/// enforcing it would require additional checks and overhead. Any `Lorentzian` -/// instances generated by the library will have positive scale factors and -/// half-widths. For manually created instances, it is the user's responsibility -/// to ensure that the parameters are valid within the context of the problem. +/// For `Lorentzian` to represent a valid peak shape, the transformed parameters +/// `sfhw` and `hw2` must be positive. This is not strictly enforced to avoid +/// unnecessary overhead. Instances created by the library are guaranteed to +/// have valid values, but if you construct a `Lorentzian` manually, you are +/// responsible for ensuring the parameters are meaningful in your context. /// /// # Thread Safety /// /// The `Lorentzian` type is both [`Send`] and [`Sync`], allowing safe sharing /// and access across threads. /// +/// # Serialization with `serde` +/// +/// When the `serde` feature is enabled, `Lorentzian` can be serialized and +/// deserialized using `serde`. During serialization, the transformation is +/// reversed, and the original parameters `(sf, hw, maxp)` are stored. +/// +/// **Important:** Since `hw2` must be non-negative, an invalid value can lead +/// to corruption when serializing, as taking the square root is needed to +/// recover `hw`. As mentioned above, the library ensures valid values, but +/// manual construction requires caution. +/// /// # Example /// /// The following demonstrates basic usage of the `Lorentzian` struct. It is, @@ -111,6 +126,11 @@ use rayon::prelude::*; /// let sup2 = Lorentzian::par_superposition_vec(&chemical_shifts, &triplet); /// ``` #[derive(Copy, Clone, Debug, Default)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(into = "SerializedLorentzian", try_from = "SerializedLorentzian") +)] pub struct Lorentzian { /// Scale factor multiplied by the half-width (`sfhw = sf * hw`). sfhw: f64, @@ -820,4 +840,24 @@ mod tests { }); } } + + #[cfg(feature = "serde")] + #[test] + fn serialization_round_trip() { + let lorentzians = vec![ + Lorentzian::new(5.5, 0.25, 3.0), + Lorentzian::new(7.0, 0.16, 5.0), + Lorentzian::new(5.5, 0.25, 7.0), + ]; + let serialized = serde_json::to_string(&lorentzians).unwrap(); + let deserialized = serde_json::from_str::>(&serialized).unwrap(); + lorentzians + .iter() + .zip(deserialized.iter()) + .for_each(|(init, rec)| { + assert_approx_eq!(f64, init.sfhw(), rec.sfhw()); + assert_approx_eq!(f64, init.hw2(), rec.hw2()); + assert_approx_eq!(f64, init.maxp(), rec.maxp()); + }); + } } diff --git a/metabodecon/src/deconvolution/peak_selection/scorer.rs b/metabodecon/src/deconvolution/peak_selection/scorer.rs index 0b02da0..5bc0faa 100644 --- a/metabodecon/src/deconvolution/peak_selection/scorer.rs +++ b/metabodecon/src/deconvolution/peak_selection/scorer.rs @@ -2,6 +2,9 @@ use crate::Result; use crate::deconvolution::Settings; use crate::deconvolution::peak_selection::Peak; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Trait interface for peak scoring methods. pub(crate) trait Scorer { /// Scores the given peak. @@ -11,6 +14,11 @@ pub(crate) trait Scorer { /// Scoring methods for the peaks. #[non_exhaustive] #[derive(Copy, Clone, Debug, Default)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(tag = "method", rename_all_fields = "camelCase") +)] pub enum ScoringMethod { /// Minimum Sum of the absolute second derivative. /// diff --git a/metabodecon/src/deconvolution/peak_selection/selector.rs b/metabodecon/src/deconvolution/peak_selection/selector.rs index b404ea4..25895a4 100644 --- a/metabodecon/src/deconvolution/peak_selection/selector.rs +++ b/metabodecon/src/deconvolution/peak_selection/selector.rs @@ -3,6 +3,9 @@ use crate::deconvolution::Settings; use crate::deconvolution::error::{Error, Kind}; use crate::deconvolution::peak_selection::{Peak, ScoringMethod}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Trait interface for peak selection algorithms. pub(crate) trait Selector { /// Detects peaks in a spectrum and returns the ones that pass a filter. @@ -17,6 +20,11 @@ pub(crate) trait Selector { /// Peak selection methods. #[non_exhaustive] #[derive(Copy, Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(tag = "method", rename_all_fields = "camelCase") +)] pub enum SelectionSettings { /// Filter based on the score of peaks found in the signal free region. /// diff --git a/metabodecon/src/deconvolution/serialized_representations.rs b/metabodecon/src/deconvolution/serialized_representations.rs new file mode 100644 index 0000000..ae5e0c4 --- /dev/null +++ b/metabodecon/src/deconvolution/serialized_representations.rs @@ -0,0 +1,5 @@ +mod serialized_deconvolution; +pub(crate) use serialized_deconvolution::SerializedDeconvolution; + +mod serialized_lorentzian; +pub(crate) use serialized_lorentzian::SerializedLorentzian; diff --git a/metabodecon/src/deconvolution/serialized_representations/serialized_deconvolution.rs b/metabodecon/src/deconvolution/serialized_representations/serialized_deconvolution.rs new file mode 100644 index 0000000..a7e8a44 --- /dev/null +++ b/metabodecon/src/deconvolution/serialized_representations/serialized_deconvolution.rs @@ -0,0 +1,172 @@ +use crate::deconvolution::fitting::FittingSettings; +use crate::deconvolution::lorentzian::Lorentzian; +use crate::deconvolution::peak_selection::SelectionSettings; +use crate::deconvolution::smoothing::SmoothingSettings; +use crate::deconvolution::{Deconvolution, Settings}; +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; + +/// Form of [`Deconvolution`] used for serialization/deserialization. +/// +/// [`Arc`] is used to store the [`Lorentzian`]s within [`Deconvolution`] to +/// allow for efficient cloning and sharing of the deconvoluted signals for the +/// alignment process. [`Arc`] can cause issues with serialization, so the +/// [`Deconvolution`] struct is converted to this form, where the `Lorentzian`s +/// are stored as a `Vec` instead. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename = "Deconvolution", rename_all = "camelCase")] +pub(crate) struct SerializedDeconvolution { + /// The deconvoluted signals. + lorentzians: Vec, + /// The smoothing parameters used. + smoothing_settings: SmoothingSettings, + /// The peak selection parameters used. + selection_settings: SelectionSettings, + /// The fitting parameters used. + fitting_settings: FittingSettings, + /// The mean squared error of the deconvolution. + mse: f64, +} + +impl> From for SerializedDeconvolution { + fn from(value: D) -> Self { + let deconvolution = value.as_ref(); + + Self { + lorentzians: deconvolution.lorentzians().to_vec(), + smoothing_settings: deconvolution.smoothing_settings(), + selection_settings: deconvolution.selection_settings(), + fitting_settings: deconvolution.fitting_settings(), + mse: deconvolution.mse(), + } + } +} + +impl TryFrom for Deconvolution { + type Error = Error; + + fn try_from(value: SerializedDeconvolution) -> Result { + value.smoothing_settings.validate()?; + value.selection_settings.validate()?; + value.fitting_settings.validate()?; + + Ok(Deconvolution::new( + value.lorentzians, + value.smoothing_settings, + value.selection_settings, + value.fitting_settings, + value.mse, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deconvolution::peak_selection::ScoringMethod; + use float_cmp::assert_approx_eq; + + #[test] + fn deconvolution_conversion_forward() { + let lorentzians = vec![ + Lorentzian::new(5.5, 0.25, 3.0), + Lorentzian::new(7.0, 0.16, 5.0), + Lorentzian::new(5.5, 0.25, 7.0), + ]; + let initial = SerializedDeconvolution { + lorentzians, + smoothing_settings: SmoothingSettings::default(), + selection_settings: SelectionSettings::default(), + fitting_settings: FittingSettings::default(), + mse: 0.5, + }; + let recovered = + SerializedDeconvolution::from(Deconvolution::try_from(initial.clone()).unwrap()); + initial + .lorentzians + .iter() + .zip(recovered.lorentzians.iter()) + .for_each(|(initial, recovered)| { + assert_approx_eq!(f64, initial.sfhw(), recovered.sfhw()); + assert_approx_eq!(f64, initial.hw2(), recovered.hw2()); + assert_approx_eq!(f64, initial.maxp(), recovered.maxp()); + }); + match recovered.smoothing_settings { + SmoothingSettings::MovingAverage { + iterations, + window_size, + } => { + assert_eq!(iterations, 2); + assert_eq!(window_size, 5); + } + }; + match recovered.selection_settings { + SelectionSettings::NoiseScoreFilter { + scoring_method, + threshold, + } => { + match scoring_method { + ScoringMethod::MinimumSum => {} + } + assert_approx_eq!(f64, threshold, 6.4); + } + }; + match recovered.fitting_settings { + FittingSettings::Analytical { iterations } => { + assert_eq!(iterations, 10); + } + }; + } + + #[test] + fn deconvolution_conversion_backward() { + let lorentzians = vec![ + Lorentzian::new(5.5, 0.25, 3.0), + Lorentzian::new(7.0, 0.16, 5.0), + Lorentzian::new(5.5, 0.25, 7.0), + ]; + let initial = Deconvolution::new( + lorentzians.clone(), + SmoothingSettings::default(), + SelectionSettings::default(), + FittingSettings::default(), + 0.5, + ); + let recovered = + Deconvolution::try_from(SerializedDeconvolution::from(initial.clone())).unwrap(); + initial + .lorentzians() + .iter() + .zip(recovered.lorentzians().iter()) + .for_each(|(init, rec)| { + assert_approx_eq!(f64, init.sfhw(), rec.sfhw()); + assert_approx_eq!(f64, init.hw2(), rec.hw2()); + assert_approx_eq!(f64, init.maxp(), rec.maxp()); + }); + match recovered.smoothing_settings() { + SmoothingSettings::MovingAverage { + iterations, + window_size, + } => { + assert_eq!(iterations, 2); + assert_eq!(window_size, 5); + } + }; + match recovered.selection_settings() { + SelectionSettings::NoiseScoreFilter { + scoring_method, + threshold, + } => { + match scoring_method { + ScoringMethod::MinimumSum => {} + } + assert_approx_eq!(f64, threshold, 6.4); + } + }; + match recovered.fitting_settings() { + FittingSettings::Analytical { iterations } => { + assert_eq!(iterations, 10); + } + }; + } +} diff --git a/metabodecon/src/deconvolution/serialized_representations/serialized_lorentzian.rs b/metabodecon/src/deconvolution/serialized_representations/serialized_lorentzian.rs new file mode 100644 index 0000000..1e82509 --- /dev/null +++ b/metabodecon/src/deconvolution/serialized_representations/serialized_lorentzian.rs @@ -0,0 +1,71 @@ +use crate::deconvolution::Lorentzian; +use serde::{Deserialize, Serialize}; + +/// Form of [`Lorentzian`] used for serialization/deserialization. +/// +/// [`Lorentzian`] internally uses transformed parameters (`sfhw`, `hw2`) to +/// improve computational efficiency, as this representation reduces redundant +/// calculations. This obscures the geometric meaning of the parameters. The +/// original parameters `(sf, hw)` directly correspond to the peak's shape: +/// - `sf` is the scale factor, controlling peak's height. +/// - `hw` is the half-width at half-maximum, defining peak width. +/// +/// Since the transformed representation is specific to internal computations, +/// this serialized form retains the conventional parameters to ensure clarity +/// and compatibility with external applications. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename = "Lorentzian")] +pub(crate) struct SerializedLorentzian { + /// Scale factor. + sf: f64, + /// Half-width. + hw: f64, + /// Maximum position. + maxp: f64, +} + +impl> From for SerializedLorentzian { + fn from(value: L) -> Self { + let lorentzian = value.as_ref(); + + Self { + sf: lorentzian.sf(), + hw: lorentzian.hw(), + maxp: lorentzian.maxp(), + } + } +} + +impl From for Lorentzian { + fn from(value: SerializedLorentzian) -> Self { + Lorentzian::new(value.sf * value.hw, value.hw.powi(2), value.maxp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use float_cmp::assert_approx_eq; + + #[test] + fn lorentzian_conversion_forward() { + let initial = SerializedLorentzian { + sf: 12.5, + hw: 0.25, + maxp: 5.0, + }; + let recovered = SerializedLorentzian::from(Lorentzian::from(initial)); + assert_approx_eq!(f64, initial.sf, recovered.sf); + assert_approx_eq!(f64, initial.hw, recovered.hw); + assert_approx_eq!(f64, initial.maxp, recovered.maxp); + } + + #[test] + fn lorentzian_conversion_backward() { + let initial = Lorentzian::new(12.5, 0.25, 5.0); + let recovered = Lorentzian::from(SerializedLorentzian::from(initial)); + assert_approx_eq!(f64, initial.sfhw(), recovered.sfhw()); + assert_approx_eq!(f64, initial.hw2(), recovered.hw2()); + assert_approx_eq!(f64, initial.maxp(), recovered.maxp()); + } +} diff --git a/metabodecon/src/deconvolution/smoothing/smoother.rs b/metabodecon/src/deconvolution/smoothing/smoother.rs index 26f4674..9491b73 100644 --- a/metabodecon/src/deconvolution/smoothing/smoother.rs +++ b/metabodecon/src/deconvolution/smoothing/smoother.rs @@ -2,6 +2,9 @@ use crate::deconvolution::Settings; use crate::deconvolution::error::{Error, Kind}; use crate::error::Result; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + /// Trait interface for smoothing algorithms. pub(crate) trait Smoother { /// Smooths the given sequence of values in place. @@ -11,6 +14,11 @@ pub(crate) trait Smoother { /// Smoothing methods for the signal intensities. #[non_exhaustive] #[derive(Copy, Clone, Debug)] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(tag = "method", rename_all_fields = "camelCase") +)] pub enum SmoothingSettings { /// Moving average low-pass filter. ///