Skip to main content

fdars_core/
wire.rs

1//! Unified FDA data container for pipeline interchange.
2//!
3//! [`FdaData`] is a single layered container that flows between pipeline nodes.
4//! Nodes read from existing layers and add new ones — data is additive, never
5//! destructive. This replaces per-type wire enums with a composable structure.
6//!
7//! # Design
8//!
9//! - **Core**: curves (FdMatrix) + argvals + metadata (grouping, scalars)
10//! - **Layers**: optional analysis results keyed by [`LayerKey`]
11//! - Nodes declare what they *require* via `require_*` helpers
12//! - Nodes add results via `set_layer`
13//! - Layers compose: FPCA + Depth + Outliers can all coexist on one `FdaData`
14//!
15//! # Example
16//!
17//! ```
18//! use fdars_core::wire::*;
19//! use fdars_core::matrix::FdMatrix;
20//!
21//! let mut fd = FdaData::from_curves(
22//!     FdMatrix::zeros(10, 50),
23//!     (0..50).map(|i| i as f64 / 49.0).collect(),
24//! );
25//!
26//! // A depth node reads curves, adds a Depth layer
27//! let scores = vec![0.5; 10];
28//! fd.set_layer(LayerKey::Depth, Layer::Depth(DepthLayer {
29//!     scores,
30//!     method: "fraiman_muniz".into(),
31//! }));
32//!
33//! // Downstream node checks what's available
34//! assert!(fd.has_layer(&LayerKey::Depth));
35//! assert!(!fd.has_layer(&LayerKey::Fpca));
36//! ```
37
38use crate::matrix::FdMatrix;
39use std::collections::HashMap;
40
41// ─── Core Container ─────────────────────────────────────────────────────────
42
43/// Unified FDA data object for pipeline interchange.
44///
45/// Carries functional data (curves + domain) plus optional analysis layers.
46/// Nodes read what they need and add their results as new layers.
47#[derive(Debug, Clone)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub struct FdaData {
50    // ── Core functional data ──
51    /// Functional observations (n × m). `None` for tabular-only data.
52    pub curves: Option<FdMatrix>,
53    /// Evaluation grid (length m).
54    pub argvals: Option<Vec<f64>>,
55
56    // ── Metadata ──
57    /// Named grouping variables (multiple allowed).
58    pub grouping: Vec<GroupVar>,
59    /// Named scalar variables (each length n).
60    pub scalar_vars: Vec<NamedVec>,
61    /// Tabular data for non-functional variables (n × p).
62    pub tabular: Option<FdMatrix>,
63    /// Column names for tabular data.
64    pub column_names: Option<Vec<String>>,
65
66    // ── Analysis layers ──
67    /// Analysis results keyed by layer type.
68    pub layers: HashMap<LayerKey, Layer>,
69}
70
71/// A named vector of f64 values (e.g., a scalar covariate or response).
72#[derive(Debug, Clone)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub struct NamedVec {
75    pub name: String,
76    pub values: Vec<f64>,
77}
78
79/// Named grouping variable with string labels.
80#[derive(Debug, Clone)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct GroupVar {
83    /// Variable name (e.g., "treatment", "sex").
84    pub name: String,
85    /// Per-observation labels (length n).
86    pub labels: Vec<String>,
87    /// Unique labels in order of first appearance.
88    pub unique: Vec<String>,
89}
90
91// ─── Layer Keys & Types ─────────────────────────────────────────────────────
92
93/// Key identifying a layer type.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
96#[non_exhaustive]
97pub enum LayerKey {
98    /// Functional PCA decomposition.
99    Fpca,
100    /// PLS decomposition.
101    Pls,
102    /// Elastic alignment (Karcher mean + warps).
103    Alignment,
104    /// Precomputed n×n distance matrix.
105    Distances,
106    /// Functional depth scores.
107    Depth,
108    /// Outlier detection flags.
109    Outliers,
110    /// Cluster assignments.
111    Clusters,
112    /// Scalar-on-function regression fit.
113    Regression,
114    /// Function-on-scalar regression fit.
115    FunctionOnScalar,
116    /// Tolerance / confidence bands.
117    Tolerance,
118    /// Mean curve.
119    Mean,
120    /// SPM Phase I chart.
121    SpmChart,
122    /// SPM Phase II monitoring result.
123    SpmMonitor,
124    /// Explainability result (SHAP, PDP, etc.).
125    Explain,
126    /// User-defined extension.
127    Custom(String),
128}
129
130/// Analysis result attached to an [`FdaData`].
131#[derive(Debug, Clone)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133#[non_exhaustive]
134pub enum Layer {
135    Fpca(FpcaLayer),
136    Pls(PlsLayer),
137    Alignment(AlignmentLayer),
138    Distances(DistancesLayer),
139    Depth(DepthLayer),
140    Outliers(OutlierLayer),
141    Clusters(ClusterLayer),
142    Regression(RegressionLayer),
143    FunctionOnScalar(FosrLayer),
144    Tolerance(ToleranceLayer),
145    Mean(MeanLayer),
146    SpmChart(SpmChartLayer),
147    SpmMonitor(SpmMonitorLayer),
148    Explain(ExplainLayer),
149    Custom(CustomLayer),
150}
151
152// ─── Layer Structs ──────────────────────────────────────────────────────────
153
154/// FPCA decomposition.
155#[derive(Debug, Clone)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub struct FpcaLayer {
158    pub eigenvalues: Vec<f64>,
159    pub variance_explained: Vec<f64>,
160    /// Eigenfunctions (m × ncomp), each column is one eigenfunction.
161    pub eigenfunctions: FdMatrix,
162    /// Scores (n × ncomp).
163    pub scores: FdMatrix,
164    /// Mean function (length m).
165    pub mean: Vec<f64>,
166    /// Integration weights (length m).
167    pub weights: Vec<f64>,
168    pub ncomp: usize,
169}
170
171/// PLS decomposition.
172#[derive(Debug, Clone)]
173#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
174pub struct PlsLayer {
175    /// Weight vectors (m × ncomp).
176    pub weights: FdMatrix,
177    /// Scores (n × ncomp).
178    pub scores: FdMatrix,
179    /// Loadings (m × ncomp).
180    pub loadings: FdMatrix,
181    pub ncomp: usize,
182}
183
184/// Elastic alignment result.
185#[derive(Debug, Clone)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
187pub struct AlignmentLayer {
188    /// Aligned curves (n × m).
189    pub aligned: FdMatrix,
190    /// Warping functions (n × m).
191    pub warps: FdMatrix,
192    /// Karcher mean (length m).
193    pub mean: Vec<f64>,
194    /// Mean SRSF (length m).
195    pub mean_srsf: Vec<f64>,
196    /// Optional: number of alignment iterations performed.
197    pub n_iter: Option<usize>,
198    /// Optional: whether the alignment converged.
199    pub converged: Option<bool>,
200}
201
202impl AlignmentLayer {
203    /// Reconstruct a [`crate::alignment::KarcherMeanResult`] from this layer's fields.
204    ///
205    /// This enables downstream functions that require `&KarcherMeanResult`
206    /// (e.g., `elastic_fpca`, `elastic_changepoint`) to work from a
207    /// serialized/restored `AlignmentLayer`.
208    pub fn to_karcher_mean_result(&self) -> crate::alignment::KarcherMeanResult {
209        crate::alignment::KarcherMeanResult {
210            mean: self.mean.clone(),
211            mean_srsf: self.mean_srsf.clone(),
212            gammas: self.warps.clone(),
213            aligned_data: self.aligned.clone(),
214            n_iter: self.n_iter.unwrap_or(0),
215            converged: self.converged.unwrap_or(true),
216            aligned_srsfs: None,
217        }
218    }
219}
220
221/// Precomputed n×n distance matrix with method metadata.
222#[derive(Debug, Clone)]
223#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
224pub struct DistancesLayer {
225    /// Symmetric n×n distance matrix.
226    pub dist_mat: FdMatrix,
227    /// Distance method used (e.g., "elastic", "l2", "dtw", "amplitude", "phase").
228    pub method: String,
229}
230
231/// Functional depth scores.
232#[derive(Debug, Clone)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub struct DepthLayer {
235    /// Depth score per observation (length n).
236    pub scores: Vec<f64>,
237    /// Method name.
238    pub method: String,
239}
240
241/// Outlier detection result.
242#[derive(Debug, Clone)]
243#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
244pub struct OutlierLayer {
245    /// Outlier flag per observation (length n).
246    pub flags: Vec<bool>,
247    /// Detection threshold.
248    pub threshold: f64,
249    /// Method name.
250    pub method: String,
251    /// Optional: MEI scores (for outliergram).
252    pub mei: Option<Vec<f64>>,
253    /// Optional: MBD scores (for outliergram).
254    pub mbd: Option<Vec<f64>>,
255    /// Optional: magnitude outlyingness.
256    pub magnitude: Option<Vec<f64>>,
257    /// Optional: shape outlyingness.
258    pub shape: Option<Vec<f64>>,
259    /// Optional: outliergram parabola intercept coefficient.
260    pub outliergram_a0: Option<f64>,
261    /// Optional: outliergram parabola linear coefficient.
262    pub outliergram_a1: Option<f64>,
263    /// Optional: outliergram parabola quadratic coefficient.
264    pub outliergram_a2: Option<f64>,
265}
266
267/// Cluster assignments.
268#[derive(Debug, Clone)]
269#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
270pub struct ClusterLayer {
271    /// Cluster label per observation (0-indexed, length n).
272    pub labels: Vec<usize>,
273    /// Number of clusters.
274    pub k: usize,
275    /// Method name.
276    pub method: String,
277    /// Optional: cluster centers (k rows × m cols).
278    pub centers: Option<FdMatrix>,
279    /// Optional: medoid indices (length k).
280    pub medoid_indices: Option<Vec<usize>>,
281    /// Optional: silhouette scores (length n).
282    pub silhouette: Option<Vec<f64>>,
283}
284
285/// Scalar-on-function regression fit.
286#[derive(Debug, Clone)]
287#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
288pub struct RegressionLayer {
289    /// Method name (e.g., "fregre_lm", "fregre_pls", "fregre_np", "elastic").
290    pub method: String,
291    /// Functional coefficient β(t) (length m). `None` for nonparametric.
292    pub beta_t: Option<Vec<f64>>,
293    /// Fitted values (length n).
294    pub fitted_values: Vec<f64>,
295    /// Residuals (length n).
296    pub residuals: Vec<f64>,
297    /// Observed response (length n).
298    pub observed_y: Vec<f64>,
299    /// R².
300    pub r_squared: f64,
301    /// Adjusted R².
302    pub adj_r_squared: Option<f64>,
303    /// Intercept.
304    pub intercept: f64,
305    /// Number of components used (0 for nonparametric).
306    pub ncomp: usize,
307    /// Evaluation grid for β(t).
308    pub argvals: Option<Vec<f64>>,
309    /// Pointwise standard errors of β(t).
310    pub beta_se: Option<Vec<f64>>,
311    /// Optional: human-readable model name.
312    pub model_name: Option<String>,
313    /// Optional: number of training observations.
314    pub n_obs: Option<usize>,
315    /// Optional: FPCA decomposition used by the model (needed for explain functions).
316    pub fpca: Option<Box<FpcaLayer>>,
317    /// Optional: model selection details (ncomp candidates, GCV/AIC/BIC scores).
318    #[cfg(feature = "serde")]
319    pub selection_extra: Option<serde_json::Value>,
320    /// Optional: model selection details.
321    #[cfg(not(feature = "serde"))]
322    pub selection_extra: Option<HashMap<String, Vec<f64>>>,
323}
324
325/// Function-on-scalar regression fit.
326#[derive(Debug, Clone)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
328pub struct FosrLayer {
329    /// Coefficient functions (p × m), one per predictor.
330    pub coefficients: FdMatrix,
331    /// Fitted curves (n × m).
332    pub fitted: FdMatrix,
333    /// R² per grid point (length m).
334    pub r_squared_t: Vec<f64>,
335}
336
337/// Tolerance / confidence band.
338#[derive(Debug, Clone)]
339#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
340pub struct ToleranceLayer {
341    /// Lower bound (length m).
342    pub lower: Vec<f64>,
343    /// Upper bound (length m).
344    pub upper: Vec<f64>,
345    /// Center (length m).
346    pub center: Vec<f64>,
347    /// Method name.
348    pub method: String,
349}
350
351/// Mean curve.
352#[derive(Debug, Clone)]
353#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
354pub struct MeanLayer {
355    /// Mean function (length m).
356    pub mean: Vec<f64>,
357}
358
359/// SPM Phase I chart.
360#[derive(Debug, Clone)]
361#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
362pub struct SpmChartLayer {
363    /// T² control limit.
364    pub t2_limit: f64,
365    /// SPE control limit.
366    pub spe_limit: f64,
367    /// Phase I T² statistics.
368    pub t2_stats: Vec<f64>,
369    /// Phase I SPE statistics.
370    pub spe_stats: Vec<f64>,
371    /// Number of FPC components.
372    pub ncomp: usize,
373    /// Significance level.
374    pub alpha: f64,
375    /// Optional: eigenvalues from FPCA (length ncomp).
376    pub eigenvalues: Option<Vec<f64>>,
377    /// Optional: FPCA mean function (length m).
378    pub fpca_mean: Option<Vec<f64>>,
379    /// Optional: FPCA rotation/eigenfunctions (m × ncomp).
380    pub fpca_rotation: Option<FdMatrix>,
381    /// Optional: FPCA integration weights (length m).
382    pub fpca_weights: Option<Vec<f64>>,
383}
384
385impl SpmChartLayer {
386    /// Create from an [`crate::spm::SpmChart`] (lossless — stores all fields needed for monitoring).
387    pub fn from_chart(chart: &crate::spm::SpmChart) -> Self {
388        Self {
389            t2_limit: chart.t2_limit.ucl,
390            spe_limit: chart.spe_limit.ucl,
391            t2_stats: chart.t2_phase1.clone(),
392            spe_stats: chart.spe_phase1.clone(),
393            ncomp: chart.eigenvalues.len(),
394            alpha: chart.config.alpha,
395            eigenvalues: Some(chart.eigenvalues.clone()),
396            fpca_mean: Some(chart.fpca.mean.clone()),
397            fpca_rotation: Some(chart.fpca.rotation.clone()),
398            fpca_weights: Some(chart.fpca.weights.clone()),
399        }
400    }
401
402    /// Check if this layer has enough state for field-based monitoring.
403    pub fn can_monitor(&self) -> bool {
404        self.eigenvalues.is_some()
405            && self.fpca_mean.is_some()
406            && self.fpca_rotation.is_some()
407            && self.fpca_weights.is_some()
408    }
409}
410
411/// SPM Phase II monitoring result.
412#[derive(Debug, Clone)]
413#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
414pub struct SpmMonitorLayer {
415    /// T² statistics for new observations.
416    pub t2_stats: Vec<f64>,
417    /// SPE statistics for new observations.
418    pub spe_stats: Vec<f64>,
419    /// T² control limit.
420    pub t2_limit: f64,
421    /// SPE control limit.
422    pub spe_limit: f64,
423    /// T² alarm flags.
424    pub t2_alarms: Vec<bool>,
425    /// SPE alarm flags.
426    pub spe_alarms: Vec<bool>,
427}
428
429/// Extra data for explain layers.
430///
431/// When the `serde` feature is enabled this is [`serde_json::Value`] (arbitrary
432/// JSON). Otherwise it is a flat `HashMap<String, Vec<f64>>`.
433#[cfg(feature = "serde")]
434pub type ExplainExtra = serde_json::Value;
435/// Extra data for explain layers (flat map when `serde` is disabled).
436#[cfg(not(feature = "serde"))]
437pub type ExplainExtra = HashMap<String, Vec<f64>>;
438
439/// Explainability result.
440#[derive(Debug, Clone)]
441#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
442pub struct ExplainLayer {
443    /// Method name (e.g., "shap", "pdp", "ale", "permutation_importance").
444    pub method: String,
445    /// Values (interpretation depends on method).
446    pub values: Vec<f64>,
447    /// Labels for the values.
448    pub labels: Vec<String>,
449    /// Additional method-specific data (arbitrary JSON when `serde` feature is
450    /// enabled, flat `HashMap<String, Vec<f64>>` otherwise).
451    pub extra: Option<ExplainExtra>,
452}
453
454/// Custom layer data type.
455///
456/// When the `serde` feature is enabled this is [`serde_json::Value`] (arbitrary
457/// JSON). Otherwise it is a flat `HashMap<String, Vec<f64>>`.
458#[cfg(feature = "serde")]
459pub type CustomData = serde_json::Value;
460/// Custom layer data type (flat map when `serde` is disabled).
461#[cfg(not(feature = "serde"))]
462pub type CustomData = HashMap<String, Vec<f64>>;
463
464/// User-defined layer for extensions.
465#[derive(Debug, Clone)]
466#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
467pub struct CustomLayer {
468    pub name: String,
469    pub data: CustomData,
470}
471
472// ─── FdaData Constructors ───────────────────────────────────────────────────
473
474impl FdaData {
475    /// Create from functional curves + grid.
476    pub fn from_curves(curves: FdMatrix, argvals: Vec<f64>) -> Self {
477        Self {
478            curves: Some(curves),
479            argvals: Some(argvals),
480            grouping: Vec::new(),
481            scalar_vars: Vec::new(),
482            tabular: None,
483            column_names: None,
484            layers: HashMap::new(),
485        }
486    }
487
488    /// Create from tabular (non-functional) data.
489    pub fn from_tabular(tabular: FdMatrix, column_names: Vec<String>) -> Self {
490        Self {
491            curves: None,
492            argvals: None,
493            grouping: Vec::new(),
494            scalar_vars: Vec::new(),
495            tabular: Some(tabular),
496            column_names: Some(column_names),
497            layers: HashMap::new(),
498        }
499    }
500
501    /// Create empty container.
502    pub fn empty() -> Self {
503        Self {
504            curves: None,
505            argvals: None,
506            grouping: Vec::new(),
507            scalar_vars: Vec::new(),
508            tabular: None,
509            column_names: None,
510            layers: HashMap::new(),
511        }
512    }
513
514    // ── Requirement checks ──
515
516    /// Require functional curves to be present.
517    pub fn require_curves(&self) -> Result<(&FdMatrix, &[f64]), String> {
518        match (&self.curves, &self.argvals) {
519            (Some(c), Some(a)) => Ok((c, a)),
520            _ => Err("FdaData requires functional curves + argvals".into()),
521        }
522    }
523
524    /// Require a specific layer to be present.
525    pub fn require_layer(&self, key: &LayerKey) -> Result<&Layer, String> {
526        self.layers
527            .get(key)
528            .ok_or_else(|| format!("FdaData missing required layer: {key:?}"))
529    }
530
531    // ── Layer access ──
532
533    /// Check if a layer is present.
534    pub fn has_layer(&self, key: &LayerKey) -> bool {
535        self.layers.contains_key(key)
536    }
537
538    /// Get a layer by key.
539    pub fn get_layer(&self, key: &LayerKey) -> Option<&Layer> {
540        self.layers.get(key)
541    }
542
543    /// Set (add or replace) a layer.
544    pub fn set_layer(&mut self, key: LayerKey, layer: Layer) {
545        self.layers.insert(key, layer);
546    }
547
548    /// Remove a layer.
549    pub fn remove_layer(&mut self, key: &LayerKey) -> Option<Layer> {
550        self.layers.remove(key)
551    }
552
553    /// List all layer keys present.
554    pub fn layer_keys(&self) -> Vec<&LayerKey> {
555        self.layers.keys().collect()
556    }
557
558    // ── Typed layer accessors ──
559
560    /// Get FPCA layer if present.
561    pub fn fpca(&self) -> Option<&FpcaLayer> {
562        match self.layers.get(&LayerKey::Fpca)? {
563            Layer::Fpca(l) => Some(l),
564            _ => None,
565        }
566    }
567
568    /// Get distances layer if present.
569    pub fn distances(&self) -> Option<&DistancesLayer> {
570        match self.layers.get(&LayerKey::Distances)? {
571            Layer::Distances(l) => Some(l),
572            _ => None,
573        }
574    }
575
576    /// Get alignment layer if present.
577    pub fn alignment(&self) -> Option<&AlignmentLayer> {
578        match self.layers.get(&LayerKey::Alignment)? {
579            Layer::Alignment(l) => Some(l),
580            _ => None,
581        }
582    }
583
584    /// Get regression layer if present.
585    pub fn regression(&self) -> Option<&RegressionLayer> {
586        match self.layers.get(&LayerKey::Regression)? {
587            Layer::Regression(l) => Some(l),
588            _ => None,
589        }
590    }
591
592    /// Get cluster layer if present.
593    pub fn clusters(&self) -> Option<&ClusterLayer> {
594        match self.layers.get(&LayerKey::Clusters)? {
595            Layer::Clusters(l) => Some(l),
596            _ => None,
597        }
598    }
599
600    /// Get depth layer if present.
601    pub fn depth(&self) -> Option<&DepthLayer> {
602        match self.layers.get(&LayerKey::Depth)? {
603            Layer::Depth(l) => Some(l),
604            _ => None,
605        }
606    }
607
608    /// Get outlier layer if present.
609    pub fn outliers(&self) -> Option<&OutlierLayer> {
610        match self.layers.get(&LayerKey::Outliers)? {
611            Layer::Outliers(l) => Some(l),
612            _ => None,
613        }
614    }
615
616    // ── Metadata helpers ──
617
618    /// Number of observations (from curves, tabular, or first scalar var).
619    pub fn n_obs(&self) -> usize {
620        if let Some(c) = &self.curves {
621            return c.nrows();
622        }
623        if let Some(t) = &self.tabular {
624            return t.nrows();
625        }
626        self.scalar_vars.first().map_or(0, |v| v.values.len())
627    }
628
629    /// Number of grid points (0 if no functional data).
630    pub fn n_points(&self) -> usize {
631        self.argvals.as_ref().map_or(0, |a| a.len())
632    }
633
634    /// Add a scalar variable.
635    pub fn add_scalar(&mut self, name: impl Into<String>, values: Vec<f64>) {
636        self.scalar_vars.push(NamedVec {
637            name: name.into(),
638            values,
639        });
640    }
641
642    /// Get a scalar variable by name.
643    pub fn get_scalar(&self, name: &str) -> Option<&[f64]> {
644        self.scalar_vars
645            .iter()
646            .find(|v| v.name == name)
647            .map(|v| v.values.as_slice())
648    }
649
650    /// Add a grouping variable with per-observation string labels.
651    ///
652    /// Unique labels are computed automatically in order of first appearance.
653    pub fn add_grouping(&mut self, name: impl Into<String>, labels: Vec<String>) {
654        let mut unique = Vec::new();
655        for lab in &labels {
656            if !unique.contains(lab) {
657                unique.push(lab.clone());
658            }
659        }
660        self.grouping.push(GroupVar {
661            name: name.into(),
662            labels,
663            unique,
664        });
665    }
666
667    /// Look up a grouping variable by name.
668    pub fn get_grouping(&self, name: &str) -> Option<&GroupVar> {
669        self.grouping.iter().find(|g| g.name == name)
670    }
671}
672
673// ─── From conversions ──────────────────────────────────────────────────────
674
675impl From<&crate::scalar_on_function::FregreLmResult> for RegressionLayer {
676    fn from(fit: &crate::scalar_on_function::FregreLmResult) -> Self {
677        let n_tune = fit.fpca.scores.nrows();
678        let eigenvalues: Vec<f64> = fit
679            .fpca
680            .singular_values
681            .iter()
682            .map(|s| s * s / (n_tune as f64 - 1.0).max(1.0))
683            .collect();
684        let total_var: f64 = eigenvalues.iter().sum();
685        let variance_explained = if total_var > 0.0 {
686            eigenvalues.iter().map(|&ev| ev / total_var).collect()
687        } else {
688            vec![0.0; eigenvalues.len()]
689        };
690
691        let fpca_layer = FpcaLayer {
692            eigenvalues,
693            variance_explained,
694            eigenfunctions: fit.fpca.rotation.clone(),
695            scores: fit.fpca.scores.clone(),
696            mean: fit.fpca.mean.clone(),
697            weights: fit.fpca.weights.clone(),
698            ncomp: fit.ncomp,
699        };
700
701        RegressionLayer {
702            method: "fregre_lm".into(),
703            beta_t: Some(fit.beta_t.clone()),
704            fitted_values: fit.fitted_values.clone(),
705            residuals: fit.residuals.clone(),
706            observed_y: Vec::new(),
707            r_squared: fit.r_squared,
708            adj_r_squared: Some(fit.r_squared_adj),
709            intercept: fit.intercept,
710            ncomp: fit.ncomp,
711            argvals: None,
712            beta_se: Some(fit.beta_se.clone()),
713            model_name: None,
714            n_obs: Some(fit.fitted_values.len()),
715            fpca: Some(Box::new(fpca_layer)),
716            selection_extra: None,
717        }
718    }
719}
720
721impl From<&crate::scalar_on_function::PlsRegressionResult> for RegressionLayer {
722    fn from(fit: &crate::scalar_on_function::PlsRegressionResult) -> Self {
723        RegressionLayer {
724            method: "fregre_pls".into(),
725            beta_t: Some(fit.beta_t.clone()),
726            fitted_values: fit.fitted_values.clone(),
727            residuals: fit.residuals.clone(),
728            observed_y: Vec::new(),
729            r_squared: fit.r_squared,
730            adj_r_squared: Some(fit.r_squared_adj),
731            intercept: fit.intercept,
732            ncomp: fit.ncomp,
733            argvals: None,
734            beta_se: None,
735            model_name: None,
736            n_obs: Some(fit.fitted_values.len()),
737            fpca: None, // PLS uses a different decomposition; no FPCA layer
738            selection_extra: None,
739        }
740    }
741}
742
743// ─── Tests ──────────────────────────────────────────────────────────────────
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn from_curves_basic() {
751        let fd = FdaData::from_curves(
752            FdMatrix::zeros(10, 50),
753            (0..50).map(|i| i as f64 / 49.0).collect(),
754        );
755        assert_eq!(fd.n_obs(), 10);
756        assert_eq!(fd.n_points(), 50);
757        assert!(fd.require_curves().is_ok());
758        assert!(!fd.has_layer(&LayerKey::Fpca));
759    }
760
761    #[test]
762    fn add_and_retrieve_layers() {
763        let mut fd = FdaData::from_curves(
764            FdMatrix::zeros(5, 20),
765            (0..20).map(|i| i as f64 / 19.0).collect(),
766        );
767
768        fd.set_layer(
769            LayerKey::Depth,
770            Layer::Depth(DepthLayer {
771                scores: vec![0.5; 5],
772                method: "fraiman_muniz".into(),
773            }),
774        );
775
776        assert!(fd.has_layer(&LayerKey::Depth));
777        assert!(!fd.has_layer(&LayerKey::Fpca));
778        assert!(fd.depth().is_some());
779        assert_eq!(fd.depth().unwrap().scores.len(), 5);
780        assert_eq!(fd.layer_keys().len(), 1);
781    }
782
783    #[test]
784    fn require_missing_layer_errors() {
785        let fd = FdaData::from_curves(FdMatrix::zeros(3, 10), vec![0.0; 10]);
786        assert!(fd.require_layer(&LayerKey::Fpca).is_err());
787    }
788
789    #[test]
790    fn scalar_vars() {
791        let mut fd = FdaData::empty();
792        fd.add_scalar("height", vec![170.0, 180.0, 165.0]);
793        assert_eq!(fd.get_scalar("height").unwrap(), &[170.0, 180.0, 165.0]);
794        assert!(fd.get_scalar("weight").is_none());
795        assert_eq!(fd.n_obs(), 3);
796    }
797
798    #[test]
799    fn multiple_layers_compose() {
800        let mut fd = FdaData::from_curves(FdMatrix::zeros(10, 30), vec![0.0; 30]);
801
802        fd.set_layer(
803            LayerKey::Depth,
804            Layer::Depth(DepthLayer {
805                scores: vec![0.5; 10],
806                method: "fm".into(),
807            }),
808        );
809        fd.set_layer(
810            LayerKey::Outliers,
811            Layer::Outliers(OutlierLayer {
812                flags: vec![false; 10],
813                threshold: 0.1,
814                method: "lrt".into(),
815                mei: None,
816                mbd: None,
817                magnitude: None,
818                shape: None,
819                outliergram_a0: None,
820                outliergram_a1: None,
821                outliergram_a2: None,
822            }),
823        );
824        fd.set_layer(
825            LayerKey::Distances,
826            Layer::Distances(DistancesLayer {
827                dist_mat: FdMatrix::zeros(10, 10),
828                method: "elastic".into(),
829            }),
830        );
831
832        assert_eq!(fd.layer_keys().len(), 3);
833        assert!(fd.depth().is_some());
834        assert!(fd.outliers().is_some());
835        assert!(fd.distances().is_some());
836    }
837
838    #[test]
839    fn regression_layer_from_fregre_lm() {
840        let (n, m) = (20, 30);
841        let data = FdMatrix::from_column_major(
842            (0..n * m)
843                .map(|k| {
844                    let i = (k % n) as f64;
845                    let j = (k / n) as f64;
846                    ((i + 1.0) * j * 0.2).sin()
847                })
848                .collect(),
849            n,
850            m,
851        )
852        .unwrap();
853        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.5).sin()).collect();
854        let fit = crate::scalar_on_function::fregre_lm(&data, &y, None, 3).unwrap();
855
856        let layer = RegressionLayer::from(&fit);
857        assert_eq!(layer.method, "fregre_lm");
858        assert_eq!(layer.ncomp, 3);
859        assert_eq!(layer.fitted_values.len(), n);
860        assert_eq!(layer.residuals.len(), n);
861        assert!(layer.fpca.is_some());
862        let fpca = layer.fpca.as_ref().unwrap();
863        assert_eq!(fpca.ncomp, 3);
864        assert_eq!(fpca.mean.len(), m);
865        assert_eq!(fpca.eigenfunctions.shape(), (m, 3));
866        assert_eq!(fpca.scores.shape(), (n, 3));
867        assert_eq!(fpca.weights.len(), m);
868        assert_eq!(fpca.eigenvalues.len(), 3);
869        // Variance explained should sum to ~1.0
870        let ve_sum: f64 = fpca.variance_explained.iter().sum();
871        assert!(
872            (ve_sum - 1.0).abs() < 1e-10,
873            "variance_explained sum = {ve_sum}"
874        );
875        assert!(layer.beta_t.is_some());
876        assert!(layer.beta_se.is_some());
877        assert_eq!(layer.n_obs, Some(n));
878        assert!((layer.r_squared - fit.r_squared).abs() < 1e-14);
879    }
880
881    #[test]
882    fn regression_layer_from_pls() {
883        let n = 30;
884        let m = 50;
885        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
886        let vals: Vec<f64> = (0..n)
887            .flat_map(|i| {
888                t.iter()
889                    .map(move |&tj| (2.0 * std::f64::consts::PI * tj).sin() + 0.1 * i as f64)
890            })
891            .collect();
892        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
893        let y: Vec<f64> = (0..n).map(|i| 2.0 + 0.5 * i as f64).collect();
894
895        let fit = crate::scalar_on_function::fregre_pls(&data, &y, &t, 3, None).unwrap();
896
897        let layer = RegressionLayer::from(&fit);
898        assert_eq!(layer.method, "fregre_pls");
899        assert_eq!(layer.ncomp, 3);
900        assert_eq!(layer.fitted_values.len(), n);
901        assert!(layer.fpca.is_none()); // PLS has no FPCA decomposition
902        assert!(layer.beta_t.is_some());
903        assert!(layer.beta_se.is_none()); // PLS has no beta SE
904        assert_eq!(layer.n_obs, Some(n));
905        assert!((layer.r_squared - fit.r_squared).abs() < 1e-14);
906    }
907}