Skip to main content

fdars_core/
multi_fdata.rs

1//! Multi-domain functional data container.
2//!
3//! [`MultiFunData`] holds several [`FdComponent`] blocks that may live on
4//! different domains/grids (the *multi-domain* feature). The only shared
5//! invariant across components is that they must all have the same number of
6//! observations (rows). Each component carries its own evaluation grid
7//! (`argvals`), whose length must equal the number of columns in its
8//! [`FdMatrix`].
9//!
10//! This mirrors the `funData::multiFunData` capability from the R `funData`
11//! package (REP-01 SC2).
12//!
13//! # Examples
14//!
15//! ```
16//! use fdars_core::matrix::FdMatrix;
17//! use fdars_core::multi_fdata::{FdComponent, MultiFunData};
18//!
19//! // Two components: 5 observations on different grids (10-point and 4-point).
20//! let data1 = FdMatrix::zeros(5, 10);
21//! let argvals1: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
22//! let comp1 = FdComponent { data: data1, argvals: argvals1 };
23//!
24//! let data2 = FdMatrix::zeros(5, 4);
25//! let argvals2: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
26//! let comp2 = FdComponent { data: data2, argvals: argvals2 };
27//!
28//! let mfd = MultiFunData::new(vec![comp1, comp2]).unwrap();
29//! assert_eq!(mfd.n_obs(), 5);
30//! assert_eq!(mfd.n_components(), 2);
31//! ```
32
33use crate::{matrix::FdMatrix, FdarError};
34
35/// A single component of a [`MultiFunData`] object.
36///
37/// Bundles an [`FdMatrix`] (rows = observations, columns = evaluation points)
38/// with its evaluation grid `argvals`. The invariant `argvals.len() ==
39/// data.ncols()` is enforced by [`MultiFunData::new`].
40#[derive(Debug, Clone, PartialEq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct FdComponent {
43    /// Functional data matrix (column-major; rows = observations, columns =
44    /// evaluation points on this component's domain).
45    pub data: FdMatrix,
46    /// Evaluation grid for this component. Must satisfy `argvals.len() ==
47    /// data.ncols()`.
48    pub argvals: Vec<f64>,
49}
50
51/// Multi-domain functional data container.
52///
53/// Holds several [`FdComponent`] blocks that may live on **different** domains
54/// (grids, lengths). The invariant shared across all components is that they
55/// must all record the same number of observations (`data.nrows()`). Each
56/// component keeps its own evaluation grid so that multi-domain data (e.g.
57/// temperature + precipitation observed at different densities) can coexist
58/// under a single object.
59///
60/// Constructed via [`MultiFunData::new`], which validates both invariants:
61/// 1. All components must have `data.nrows() == n_obs` (equal observation count).
62/// 2. Each component must have `argvals.len() == data.ncols()`.
63///
64/// # Examples
65///
66/// ```
67/// use fdars_core::matrix::FdMatrix;
68/// use fdars_core::multi_fdata::{FdComponent, MultiFunData};
69///
70/// let n = 8;
71/// let comp_a = FdComponent {
72///     data: FdMatrix::zeros(n, 20),
73///     argvals: (0..20).map(|i| i as f64).collect(),
74/// };
75/// let comp_b = FdComponent {
76///     data: FdMatrix::zeros(n, 5),
77///     argvals: vec![0.0, 0.25, 0.5, 0.75, 1.0],
78/// };
79/// let mfd = MultiFunData::new(vec![comp_a, comp_b]).unwrap();
80/// assert_eq!(mfd.n_obs(), n);
81/// assert_eq!(mfd.n_components(), 2);
82/// ```
83#[derive(Debug, Clone, PartialEq)]
84#[non_exhaustive]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct MultiFunData {
87    components: Vec<FdComponent>,
88}
89
90impl MultiFunData {
91    /// Construct a validated multi-domain functional data container.
92    ///
93    /// # Invariants enforced
94    ///
95    /// 1. `components` must be non-empty.
96    /// 2. All components must share the same observation count (`data.nrows()`).
97    /// 3. Each component must satisfy `argvals.len() == data.ncols()`.
98    ///
99    /// # Errors
100    ///
101    /// - [`FdarError::InvalidParameter`] — `components` is empty.
102    /// - [`FdarError::InvalidDimension`] — observation count mismatch across
103    ///   components, or `argvals.len() != data.ncols()` for any component.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use fdars_core::matrix::FdMatrix;
109    /// use fdars_core::multi_fdata::{FdComponent, MultiFunData};
110    ///
111    /// let comp = FdComponent {
112    ///     data: FdMatrix::zeros(3, 6),
113    ///     argvals: vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
114    /// };
115    /// let mfd = MultiFunData::new(vec![comp]).unwrap();
116    /// assert_eq!(mfd.n_obs(), 3);
117    /// ```
118    pub fn new(components: Vec<FdComponent>) -> Result<Self, FdarError> {
119        if components.is_empty() {
120            return Err(FdarError::InvalidParameter {
121                parameter: "components",
122                message: "MultiFunData requires at least one component".to_string(),
123            });
124        }
125
126        let n_obs = components[0].data.nrows();
127
128        // Validate argvals length for the first component before iterating.
129        if components[0].argvals.len() != components[0].data.ncols() {
130            return Err(FdarError::InvalidDimension {
131                parameter: "components[0].argvals",
132                expected: format!("{}", components[0].data.ncols()),
133                actual: format!("{}", components[0].argvals.len()),
134            });
135        }
136
137        for (k, comp) in components.iter().enumerate().skip(1) {
138            // Check observation count consistency.
139            if comp.data.nrows() != n_obs {
140                return Err(FdarError::InvalidDimension {
141                    parameter: "components[k].data.nrows",
142                    expected: format!("{n_obs} (same as component 0)"),
143                    actual: format!("{} (component {k})", comp.data.nrows()),
144                });
145            }
146            // Check argvals-length vs ncols.
147            if comp.argvals.len() != comp.data.ncols() {
148                return Err(FdarError::InvalidDimension {
149                    parameter: "components[k].argvals",
150                    expected: format!("{} (data.ncols for component {k})", comp.data.ncols()),
151                    actual: format!("{}", comp.argvals.len()),
152                });
153            }
154        }
155
156        Ok(Self { components })
157    }
158
159    /// Number of observations shared by all components.
160    ///
161    /// # Panics
162    ///
163    /// Never — `components` is always non-empty after [`MultiFunData::new`].
164    #[inline]
165    pub fn n_obs(&self) -> usize {
166        self.components[0].data.nrows()
167    }
168
169    /// Number of components.
170    #[inline]
171    pub fn n_components(&self) -> usize {
172        self.components.len()
173    }
174
175    /// Return a reference to the `k`-th component.
176    ///
177    /// # Errors
178    ///
179    /// [`FdarError::InvalidParameter`] if `k >= n_components()`.
180    pub fn component(&self, k: usize) -> Result<&FdComponent, FdarError> {
181        if k >= self.components.len() {
182            return Err(FdarError::InvalidParameter {
183                parameter: "k",
184                message: format!(
185                    "component index {k} out of range (n_components = {})",
186                    self.components.len()
187                ),
188            });
189        }
190        Ok(&self.components[k])
191    }
192
193    /// Return a reference to the evaluation grid of the `k`-th component.
194    ///
195    /// # Errors
196    ///
197    /// [`FdarError::InvalidParameter`] if `k >= n_components()`.
198    pub fn argvals(&self, k: usize) -> Result<&[f64], FdarError> {
199        if k >= self.components.len() {
200            return Err(FdarError::InvalidParameter {
201                parameter: "k",
202                message: format!(
203                    "argvals index {k} out of range (n_components = {})",
204                    self.components.len()
205                ),
206            });
207        }
208        Ok(&self.components[k].argvals)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::matrix::FdMatrix;
216
217    fn make_component(nrows: usize, ncols: usize) -> FdComponent {
218        FdComponent {
219            data: FdMatrix::zeros(nrows, ncols),
220            argvals: (0..ncols).map(|i| i as f64).collect(),
221        }
222    }
223
224    fn make_component_argvals(nrows: usize, argvals: Vec<f64>) -> FdComponent {
225        let ncols = argvals.len();
226        FdComponent {
227            data: FdMatrix::zeros(nrows, ncols),
228            argvals,
229        }
230    }
231
232    // --- Constructor tests ---
233
234    #[test]
235    fn test_two_component_different_grids_ok() {
236        // Multi-domain: two components with different ncols (different grids).
237        let comp1 = make_component(5, 10);
238        let comp2 = make_component(5, 4);
239        let mfd = MultiFunData::new(vec![comp1, comp2]).unwrap();
240        assert_eq!(mfd.n_obs(), 5);
241        assert_eq!(mfd.n_components(), 2);
242    }
243
244    #[test]
245    fn test_single_component_ok() {
246        let comp = make_component(3, 6);
247        let mfd = MultiFunData::new(vec![comp]).unwrap();
248        assert_eq!(mfd.n_obs(), 3);
249        assert_eq!(mfd.n_components(), 1);
250    }
251
252    #[test]
253    fn test_three_components_same_nrows_ok() {
254        let comp1 = make_component(7, 5);
255        let comp2 = make_component(7, 10);
256        let comp3 = make_component(7, 3);
257        let mfd = MultiFunData::new(vec![comp1, comp2, comp3]).unwrap();
258        assert_eq!(mfd.n_obs(), 7);
259        assert_eq!(mfd.n_components(), 3);
260    }
261
262    #[test]
263    fn test_empty_components_err() {
264        let result = MultiFunData::new(vec![]);
265        assert!(matches!(result, Err(FdarError::InvalidParameter { .. })));
266    }
267
268    #[test]
269    fn test_mismatched_nrows_err() {
270        let comp1 = make_component(5, 10);
271        let comp2 = make_component(4, 10); // 4 rows, should be 5
272        let result = MultiFunData::new(vec![comp1, comp2]);
273        assert!(matches!(result, Err(FdarError::InvalidDimension { .. })));
274    }
275
276    #[test]
277    fn test_argvals_len_mismatch_first_component_err() {
278        // argvals.len() != data.ncols() for component 0
279        let comp = FdComponent {
280            data: FdMatrix::zeros(5, 10),
281            argvals: vec![0.0, 1.0, 2.0], // len=3, ncols=10
282        };
283        let result = MultiFunData::new(vec![comp]);
284        assert!(matches!(result, Err(FdarError::InvalidDimension { .. })));
285    }
286
287    #[test]
288    fn test_argvals_len_mismatch_later_component_err() {
289        // argvals.len() != data.ncols() for component 1
290        let comp1 = make_component(5, 10);
291        let comp2 = FdComponent {
292            data: FdMatrix::zeros(5, 4),
293            argvals: vec![0.0, 1.0], // len=2, ncols=4
294        };
295        let result = MultiFunData::new(vec![comp1, comp2]);
296        assert!(matches!(result, Err(FdarError::InvalidDimension { .. })));
297    }
298
299    // --- Accessor tests ---
300
301    #[test]
302    fn test_component_accessor_valid() {
303        let argvals1: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
304        let argvals2: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
305        let comp1 = make_component_argvals(5, argvals1.clone());
306        let comp2 = make_component_argvals(5, argvals2.clone());
307        let mfd = MultiFunData::new(vec![comp1, comp2]).unwrap();
308
309        let c0 = mfd.component(0).unwrap();
310        assert_eq!(c0.argvals, argvals1);
311        assert_eq!(c0.data.nrows(), 5);
312        assert_eq!(c0.data.ncols(), 10);
313
314        let c1 = mfd.component(1).unwrap();
315        assert_eq!(c1.argvals, argvals2);
316        assert_eq!(c1.data.ncols(), 4);
317    }
318
319    #[test]
320    fn test_component_accessor_out_of_range_err() {
321        let mfd = MultiFunData::new(vec![make_component(3, 5)]).unwrap();
322        let result = mfd.component(1);
323        assert!(matches!(result, Err(FdarError::InvalidParameter { .. })));
324    }
325
326    #[test]
327    fn test_argvals_accessor_valid() {
328        let argvals: Vec<f64> = vec![0.0, 0.5, 1.0];
329        let comp = make_component_argvals(4, argvals.clone());
330        let mfd = MultiFunData::new(vec![comp]).unwrap();
331        assert_eq!(mfd.argvals(0).unwrap(), argvals.as_slice());
332    }
333
334    #[test]
335    fn test_argvals_accessor_out_of_range_err() {
336        let mfd = MultiFunData::new(vec![make_component(3, 5)]).unwrap();
337        let result = mfd.argvals(5);
338        assert!(matches!(result, Err(FdarError::InvalidParameter { .. })));
339    }
340
341    #[test]
342    fn test_component_accessor_preserves_argvals_per_component() {
343        // Ensure each component's argvals are preserved independently.
344        let argvals1: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0];
345        let argvals2: Vec<f64> = vec![10.0, 20.0];
346        let comp1 = make_component_argvals(6, argvals1.clone());
347        let comp2 = make_component_argvals(6, argvals2.clone());
348        let mfd = MultiFunData::new(vec![comp1, comp2]).unwrap();
349
350        assert_eq!(mfd.argvals(0).unwrap(), argvals1.as_slice());
351        assert_eq!(mfd.argvals(1).unwrap(), argvals2.as_slice());
352    }
353
354    #[test]
355    fn test_no_panic_on_out_of_range_component() {
356        let mfd = MultiFunData::new(vec![make_component(2, 3)]).unwrap();
357        // These must never panic — they return Err.
358        assert!(mfd.component(100).is_err());
359        assert!(mfd.argvals(100).is_err());
360        assert!(mfd.component(usize::MAX).is_err());
361    }
362
363    // --- Trait derive tests ---
364
365    #[test]
366    fn test_debug_clone_partialeq() {
367        let comp = make_component(2, 3);
368        let mfd = MultiFunData::new(vec![comp]).unwrap();
369        let mfd2 = mfd.clone();
370        assert_eq!(mfd, mfd2);
371        let s = format!("{:?}", mfd);
372        assert!(s.contains("MultiFunData"));
373    }
374
375    #[test]
376    fn test_fdcomponent_debug_clone_partialeq() {
377        let comp = make_component(2, 4);
378        let comp2 = comp.clone();
379        assert_eq!(comp, comp2);
380        let s = format!("{:?}", comp);
381        assert!(s.contains("FdComponent"));
382    }
383}