Skip to main content

scirs2_interpolate/physics_informed/
auto_select.rs

1//! Automatic interpolation method selection based on data characteristics.
2//!
3//! `analyze_data` builds a `DataProfile` by examining the data's dimensionality,
4//! size, smoothness, noise, and periodicity.  `recommend_method` then applies a
5//! set of decision rules to select the most appropriate interpolation strategy.
6//!
7//! # Decision Rules (in priority order)
8//!
9//! | Condition | Method |
10//! |-----------|--------|
11//! | `n_dims == 1 && !noisy` | `CubicSpline` |
12//! | `n_dims ≤ 4 && n_points < 500` | `RadialBasis` |
13//! | `n_dims ≤ 6 && n_points < 10_000` | `TensorProduct` |
14//! | `n_dims > 6 && n_points > 1_000` | `SparseGrid` |
15//! | `n_dims > 10` | `TensorTrain` |
16//! | default | `RadialBasis` |
17
18use crate::error::InterpolateError;
19
20// ─────────────────────────────────────────────────────────────────────────────
21// Public types
22// ─────────────────────────────────────────────────────────────────────────────
23
24/// Summary statistics derived from data analysis.
25#[derive(Debug, Clone)]
26pub struct DataProfile {
27    /// Number of data points.
28    pub n_points: usize,
29    /// Number of input dimensions.
30    pub n_dims: usize,
31    /// Estimated smoothness (ratio of second-difference norm to value norm;
32    /// lower is smoother).
33    pub smoothness_estimate: f64,
34    /// Whether the data appears to be noisy.
35    pub has_noise: bool,
36    /// Whether the data appears to be periodic.
37    pub is_periodic: bool,
38}
39
40/// Interpolation method recommendation.
41#[non_exhaustive]
42#[derive(Debug, Clone, PartialEq)]
43pub enum InterpolationMethod {
44    /// Piecewise linear interpolation (fast, first-order accurate).
45    LinearSpline,
46    /// Piecewise cubic spline (smooth, good for 1-D well-sampled data).
47    CubicSpline,
48    /// Radial basis function interpolation (flexible, handles scattered data).
49    RadialBasis,
50    /// Tensor-product interpolation on structured grids (efficient up to ~6-D).
51    TensorProduct,
52    /// Sparse-grid interpolation (Smolyak; handles moderate-dimensional spaces).
53    SparseGrid,
54    /// Tensor-train (TT/MPS) decomposition (handles very high dimensions).
55    TensorTrain,
56}
57
58impl std::fmt::Display for InterpolationMethod {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        let name = match self {
61            InterpolationMethod::LinearSpline => "LinearSpline",
62            InterpolationMethod::CubicSpline => "CubicSpline",
63            InterpolationMethod::RadialBasis => "RadialBasis",
64            InterpolationMethod::TensorProduct => "TensorProduct",
65            InterpolationMethod::SparseGrid => "SparseGrid",
66            InterpolationMethod::TensorTrain => "TensorTrain",
67        };
68        write!(f, "{}", name)
69    }
70}
71
72// ─────────────────────────────────────────────────────────────────────────────
73// Data analysis
74// ─────────────────────────────────────────────────────────────────────────────
75
76/// Analyse data to build a `DataProfile`.
77///
78/// # Arguments
79/// * `x` – Input points, each of length `n_dims`.  Must be non-empty.
80/// * `y` – Function values.  Must have the same length as `x`.
81///
82/// # Smoothness estimation
83///
84/// For 1-D data, the smoothness estimate is the RMS of second-order finite
85/// differences normalised by the RMS of the values.  A small ratio (< 0.1)
86/// indicates a smooth function; a large ratio indicates roughness or noise.
87///
88/// For multi-dimensional data we use only the first-coordinate ordering.
89///
90/// # Noise detection
91///
92/// The data is considered noisy if `smoothness_estimate > 0.3`.
93///
94/// # Periodicity detection
95///
96/// If `|y.first() - y.last()| / max(|y|)  < 0.05` the data is considered
97/// (approximately) periodic.
98pub fn analyze_data(x: &[Vec<f64>], y: &[f64]) -> DataProfile {
99    let n_points = x.len();
100    let n_dims = if n_points > 0 { x[0].len() } else { 0 };
101
102    if n_points < 3 || n_dims == 0 {
103        return DataProfile {
104            n_points,
105            n_dims,
106            smoothness_estimate: 0.0,
107            has_noise: false,
108            is_periodic: false,
109        };
110    }
111
112    // ── Smoothness: second-order finite differences on y (sorted by x[0]) ─
113
114    // Sort by first coordinate.
115    let mut order: Vec<usize> = (0..n_points).collect();
116    order.sort_by(|&a, &b| {
117        x[a][0]
118            .partial_cmp(&x[b][0])
119            .unwrap_or(std::cmp::Ordering::Equal)
120    });
121
122    let y_sorted: Vec<f64> = order.iter().map(|&i| y[i]).collect();
123
124    let rms_y = (y_sorted.iter().map(|&v| v * v).sum::<f64>() / n_points as f64)
125        .sqrt()
126        .max(1e-12);
127
128    let second_diff_rms = if n_points >= 3 {
129        let n = y_sorted.len();
130        let ss: f64 = (1..(n - 1))
131            .map(|i| {
132                let d2 = y_sorted[i + 1] - 2.0 * y_sorted[i] + y_sorted[i - 1];
133                d2 * d2
134            })
135            .sum::<f64>();
136        (ss / (n - 2) as f64).sqrt()
137    } else {
138        0.0
139    };
140
141    let smoothness_estimate = second_diff_rms / rms_y;
142
143    // ── Noise detection ────────────────────────────────────────────────────
144    let has_noise = smoothness_estimate > 0.3;
145
146    // ── Periodicity detection ──────────────────────────────────────────────
147    let y_max_abs = y_sorted
148        .iter()
149        .map(|v| v.abs())
150        .fold(0.0_f64, f64::max)
151        .max(1e-12);
152    let endpoint_diff = (y_sorted[0] - y_sorted[n_points - 1]).abs();
153    let is_periodic = endpoint_diff / y_max_abs < 0.05;
154
155    DataProfile {
156        n_points,
157        n_dims,
158        smoothness_estimate,
159        has_noise,
160        is_periodic,
161    }
162}
163
164// ─────────────────────────────────────────────────────────────────────────────
165// Method recommendation
166// ─────────────────────────────────────────────────────────────────────────────
167
168/// Apply decision rules to select an interpolation method.
169///
170/// See the module-level documentation for the full rule table.
171pub fn recommend_method(profile: &DataProfile) -> InterpolationMethod {
172    let d = profile.n_dims;
173    let n = profile.n_points;
174
175    if d == 1 && !profile.has_noise {
176        return InterpolationMethod::CubicSpline;
177    }
178    if d > 10 {
179        return InterpolationMethod::TensorTrain;
180    }
181    if d <= 4 && n < 500 {
182        return InterpolationMethod::RadialBasis;
183    }
184    if d <= 6 && n < 10_000 {
185        return InterpolationMethod::TensorProduct;
186    }
187    if d > 6 && n > 1_000 {
188        return InterpolationMethod::SparseGrid;
189    }
190    // Default
191    InterpolationMethod::RadialBasis
192}
193
194/// Apply decision rules and return the chosen method together with a
195/// human-readable rationale string.
196pub fn recommend_with_rationale(profile: &DataProfile) -> (InterpolationMethod, String) {
197    let d = profile.n_dims;
198    let n = profile.n_points;
199
200    if d == 1 && !profile.has_noise {
201        return (
202            InterpolationMethod::CubicSpline,
203            format!(
204                "1-D data ({n} points) without noise: CubicSpline gives smooth, \
205                 C² interpolation at O(n) cost."
206            ),
207        );
208    }
209    if d > 10 {
210        return (
211            InterpolationMethod::TensorTrain,
212            format!(
213                "{d}-D data ({n} points): dimensionality exceeds 10; \
214                 TensorTrain (TT-SVD/TT-cross) avoids the curse of dimensionality."
215            ),
216        );
217    }
218    if d <= 4 && n < 500 {
219        return (
220            InterpolationMethod::RadialBasis,
221            format!(
222                "{d}-D scattered data ({n} points): RBF provides flexible \
223                 interpolation without a grid structure."
224            ),
225        );
226    }
227    if d <= 6 && n < 10_000 {
228        return (
229            InterpolationMethod::TensorProduct,
230            format!(
231                "{d}-D data ({n} points): a tensor-product grid is feasible \
232                 and gives fast O(n) evaluation per dimension."
233            ),
234        );
235    }
236    if d > 6 && n > 1_000 {
237        return (
238            InterpolationMethod::SparseGrid,
239            format!(
240                "{d}-D data ({n} points): Smolyak sparse grid reduces the \
241                 exponential cost of tensor-product methods in moderate dimensions."
242            ),
243        );
244    }
245
246    (
247        InterpolationMethod::RadialBasis,
248        format!(
249            "Default choice for {d}-D data ({n} points): RBF interpolation \
250             works well for general scattered data."
251        ),
252    )
253}
254
255/// Validate input slices for consistency (helper used internally).
256#[allow(dead_code)]
257pub(crate) fn validate_input(x: &[Vec<f64>], y: &[f64]) -> Result<(), InterpolateError> {
258    if x.len() != y.len() {
259        return Err(InterpolateError::DimensionMismatch(format!(
260            "x has {} points but y has {} values",
261            x.len(),
262            y.len()
263        )));
264    }
265    Ok(())
266}
267
268// ─────────────────────────────────────────────────────────────────────────────
269// Tests
270// ─────────────────────────────────────────────────────────────────────────────
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn make_1d_data(n: usize) -> (Vec<Vec<f64>>, Vec<f64>) {
277        let x: Vec<Vec<f64>> = (0..n).map(|i| vec![i as f64 / n as f64]).collect();
278        let y: Vec<f64> = x.iter().map(|p| p[0] * p[0]).collect();
279        (x, y)
280    }
281
282    fn make_nd_data(n: usize, d: usize) -> (Vec<Vec<f64>>, Vec<f64>) {
283        let x: Vec<Vec<f64>> = (0..n).map(|i| vec![i as f64 / n as f64; d]).collect();
284        let y: Vec<f64> = x.iter().map(|p| p.iter().sum::<f64>()).collect();
285        (x, y)
286    }
287
288    #[test]
289    fn test_1d_smooth_data_recommends_cubic_spline() {
290        let (x, y) = make_1d_data(50);
291        let profile = analyze_data(&x, &y);
292        assert_eq!(profile.n_dims, 1);
293        let method = recommend_method(&profile);
294        assert_eq!(method, InterpolationMethod::CubicSpline);
295    }
296
297    #[test]
298    fn test_high_dim_recommends_tensor_train() {
299        let (x, y) = make_nd_data(2000, 15);
300        let profile = analyze_data(&x, &y);
301        let method = recommend_method(&profile);
302        assert_eq!(method, InterpolationMethod::TensorTrain);
303    }
304
305    #[test]
306    fn test_moderate_dim_recommends_sparse_grid() {
307        // 8-D, 2000 points
308        let (x, y) = make_nd_data(2000, 8);
309        let profile = analyze_data(&x, &y);
310        let method = recommend_method(&profile);
311        assert_eq!(method, InterpolationMethod::SparseGrid);
312    }
313
314    #[test]
315    fn test_small_4d_recommends_rbf() {
316        let (x, y) = make_nd_data(100, 4);
317        let profile = analyze_data(&x, &y);
318        let method = recommend_method(&profile);
319        assert_eq!(method, InterpolationMethod::RadialBasis);
320    }
321
322    #[test]
323    fn test_recommend_with_rationale_returns_string() {
324        let (x, y) = make_1d_data(20);
325        let profile = analyze_data(&x, &y);
326        let (method, reason) = recommend_with_rationale(&profile);
327        assert_eq!(method, InterpolationMethod::CubicSpline);
328        assert!(!reason.is_empty(), "rationale string should not be empty");
329    }
330
331    #[test]
332    fn test_analyze_data_smoothness_for_noisy_data() {
333        // Noisy data: add random-looking second differences.
334        let x: Vec<Vec<f64>> = (0..20).map(|i| vec![i as f64 * 0.1]).collect();
335        // Alternating sign induces large second differences → noisy.
336        let y: Vec<f64> = (0..20)
337            .map(|i| if i % 2 == 0 { 0.0 } else { 1.0 })
338            .collect();
339        let profile = analyze_data(&x, &y);
340        assert!(
341            profile.has_noise,
342            "alternating data should be flagged as noisy"
343        );
344    }
345
346    #[test]
347    fn test_periodicity_detected() {
348        // Sine on a closed interval [0, 2π] with equal endpoints (both ≈ 0).
349        use std::f64::consts::PI;
350        let n = 65_usize; // 65 points so first and last are both x=0 and x=2π
351        let x: Vec<Vec<f64>> = (0..n)
352            .map(|i| vec![i as f64 * 2.0 * PI / (n - 1) as f64])
353            .collect();
354        let y: Vec<f64> = x.iter().map(|p| p[0].sin()).collect();
355        // y[0] = sin(0) = 0.0, y[n-1] = sin(2π) ≈ 0.0
356        let profile = analyze_data(&x, &y);
357        assert!(
358            profile.is_periodic,
359            "sin data on [0,2π] should be detected as periodic; y[0]={:.4}, y[last]={:.4}",
360            y[0],
361            y[n - 1]
362        );
363    }
364
365    #[test]
366    fn test_empty_data_no_panic() {
367        let profile = analyze_data(&[], &[]);
368        assert_eq!(profile.n_points, 0);
369        assert_eq!(profile.n_dims, 0);
370    }
371}