Skip to main content

scirs2_spatial/
variogram.rs

1//! Variogram analysis for spatial statistics
2//!
3//! This module provides tools for variogram analysis, which is fundamental to
4//! geostatistics and spatial interpolation methods like kriging.
5//!
6//! # Features
7//!
8//! * **Experimental variogram computation** - Calculate empirical variograms from data
9//! * **Theoretical variogram models** - Fit standard models (spherical, exponential, Gaussian, etc.)
10//! * **Variogram fitting** - Optimize model parameters
11//! * **Directional variograms** - Anisotropic spatial correlation analysis
12//! * **Cross-variograms** - Multivariate spatial correlation
13//!
14//! # Examples
15//!
16//! ```
17//! use scirs2_core::ndarray::array;
18//! use scirs2_spatial::variogram::{experimental_variogram, VariogramModel, fit_variogram};
19//!
20//! // Create spatial data
21//! let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
22//! let values = array![1.0, 2.0, 1.5, 2.5];
23//!
24//! // Compute experimental variogram
25//! let (lags, gamma) = experimental_variogram(
26//!     &coords.view(),
27//!     &values.view(),
28//!     10,   // number of lag bins
29//!     None  // automatic lag tolerance
30//! ).expect("Failed to compute variogram");
31//!
32//! // Fit theoretical model
33//! let model = fit_variogram(&lags, &gamma, VariogramModel::Spherical)
34//!     .expect("Failed to fit model");
35//!
36//! println!("Fitted parameters: range={:.2}, sill={:.2}, nugget={:.2}",
37//!          model.range, model.sill, model.nugget);
38//! ```
39
40use crate::error::{SpatialError, SpatialResult};
41use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
42use scirs2_core::numeric::Float;
43use std::f64::consts::PI;
44
45/// Variogram model types
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub enum VariogramModel {
48    /// Spherical model: γ(h) = nugget + sill * [1.5(h/range) - 0.5(h/range)³] for h < range
49    Spherical,
50    /// Exponential model: γ(h) = nugget + sill * [1 - exp(-h/range)]
51    Exponential,
52    /// Gaussian model: γ(h) = nugget + sill * [1 - exp(-(h/range)²)]
53    Gaussian,
54    /// Linear model: γ(h) = nugget + slope * h
55    Linear,
56    /// Power model: γ(h) = nugget + scale * h^power
57    Power,
58    /// Matérn model with smoothness parameter
59    Matern,
60}
61
62/// Fitted variogram parameters
63#[derive(Debug, Clone)]
64pub struct FittedVariogram<T: Float> {
65    /// Model type
66    pub model: VariogramModel,
67    /// Range parameter (distance at which correlation becomes negligible)
68    pub range: T,
69    /// Sill parameter (variance at infinite distance)
70    pub sill: T,
71    /// Nugget parameter (variance at zero distance, measurement error)
72    pub nugget: T,
73    /// Additional parameters for specific models
74    pub extra_params: Vec<T>,
75    /// Goodness of fit (R²)
76    pub r_squared: T,
77}
78
79impl<T: Float> FittedVariogram<T> {
80    /// Evaluate the variogram model at a given distance
81    pub fn evaluate(&self, distance: T) -> T {
82        match self.model {
83            VariogramModel::Spherical => {
84                if distance >= self.range {
85                    self.nugget + self.sill
86                } else {
87                    let h_over_r = distance / self.range;
88                    let three_halves = T::from(1.5).expect("conversion failed");
89                    let half = T::from(0.5).expect("conversion failed");
90                    self.nugget
91                        + self.sill
92                            * (three_halves * h_over_r - half * h_over_r * h_over_r * h_over_r)
93                }
94            }
95            VariogramModel::Exponential => {
96                self.nugget + self.sill * (T::one() - (-distance / self.range).exp())
97            }
98            VariogramModel::Gaussian => {
99                let h_over_r = distance / self.range;
100                self.nugget + self.sill * (T::one() - (-(h_over_r * h_over_r)).exp())
101            }
102            VariogramModel::Linear => {
103                let slope = if !self.extra_params.is_empty() {
104                    self.extra_params[0]
105                } else {
106                    self.sill / self.range
107                };
108                self.nugget + slope * distance
109            }
110            VariogramModel::Power => {
111                let power = if !self.extra_params.is_empty() {
112                    self.extra_params[0]
113                } else {
114                    T::from(0.5).expect("conversion failed")
115                };
116                self.nugget + self.sill * distance.powf(power)
117            }
118            VariogramModel::Matern => {
119                let nu = if !self.extra_params.is_empty() {
120                    self.extra_params[0]
121                } else {
122                    T::from(1.5).expect("conversion failed") // Default smoothness
123                };
124                // Simplified Matérn for common nu values
125                if distance.is_zero() {
126                    self.nugget
127                } else {
128                    let scaled_dist = distance / self.range
129                        * T::from(2.0).expect("conversion failed")
130                        * nu.sqrt();
131                    // Approximation for nu = 1.5 (most common)
132                    let term = (T::one() + scaled_dist) * (-scaled_dist).exp();
133                    self.nugget + self.sill * (T::one() - term)
134                }
135            }
136        }
137    }
138}
139
140/// Compute experimental (empirical) variogram from spatial data
141///
142/// # Arguments
143///
144/// * `coordinates` - Spatial coordinates of observations (n × d array)
145/// * `values` - Observed values at each location (n-vector)
146/// * `n_lags` - Number of lag distance bins
147/// * `lag_tolerance` - Tolerance for binning distances (None for automatic)
148///
149/// # Returns
150///
151/// * Tuple of (lag distances, variogram values)
152///
153/// # Examples
154///
155/// ```
156/// use scirs2_core::ndarray::array;
157/// use scirs2_spatial::variogram::experimental_variogram;
158///
159/// let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
160/// let values = array![1.0, 2.0, 1.5, 2.5];
161///
162/// let (lags, gamma) = experimental_variogram(&coords.view(), &values.view(), 10, None)
163///     .expect("Failed to compute variogram");
164/// ```
165pub fn experimental_variogram<T: Float>(
166    coordinates: &ArrayView2<T>,
167    values: &ArrayView1<T>,
168    n_lags: usize,
169    lag_tolerance: Option<T>,
170) -> SpatialResult<(Array1<T>, Array1<T>)> {
171    let n = coordinates.shape()[0];
172
173    if n != values.len() {
174        return Err(SpatialError::DimensionError(
175            "Number of coordinates must match number of values".to_string(),
176        ));
177    }
178
179    if n < 2 {
180        return Err(SpatialError::ValueError(
181            "Need at least 2 points for variogram".to_string(),
182        ));
183    }
184
185    // Compute all pairwise distances and squared differences
186    let mut pairs = Vec::new();
187    for i in 0..n {
188        for j in (i + 1)..n {
189            let mut dist_sq = T::zero();
190            for k in 0..coordinates.shape()[1] {
191                let diff = coordinates[[i, k]] - coordinates[[j, k]];
192                dist_sq = dist_sq + diff * diff;
193            }
194            let distance = dist_sq.sqrt();
195
196            let value_diff = values[i] - values[j];
197            let gamma = value_diff * value_diff / (T::one() + T::one()); // γ = 0.5 * (z_i - z_j)²
198
199            pairs.push((distance, gamma));
200        }
201    }
202
203    if pairs.is_empty() {
204        return Err(SpatialError::ValueError("No valid pairs found".to_string()));
205    }
206
207    // Find maximum distance
208    let max_distance = pairs
209        .iter()
210        .map(|(d, _)| *d)
211        .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
212        .ok_or_else(|| SpatialError::ValueError("Failed to find max distance".to_string()))?;
213
214    // Determine lag size
215    let lag_size = max_distance / T::from(n_lags).expect("conversion failed");
216    let tolerance = lag_tolerance.unwrap_or(lag_size / (T::one() + T::one()));
217
218    // Bin pairs into lags
219    let mut lag_bins: Vec<Vec<T>> = vec![Vec::new(); n_lags];
220    let mut lag_centers = Array1::zeros(n_lags);
221
222    for i in 0..n_lags {
223        let lag_center = lag_size
224            * (T::from(i).expect("conversion failed") + T::from(0.5).expect("conversion failed"));
225        lag_centers[i] = lag_center;
226
227        for &(distance, gamma) in &pairs {
228            if (distance - lag_center).abs() <= tolerance {
229                lag_bins[i].push(gamma);
230            }
231        }
232    }
233
234    // Compute mean variogram value for each lag
235    let mut gamma_values = Array1::zeros(n_lags);
236    let mut valid_lags = Vec::new();
237    let mut valid_gammas = Vec::new();
238
239    for i in 0..n_lags {
240        if !lag_bins[i].is_empty() {
241            let sum: T = lag_bins[i]
242                .iter()
243                .copied()
244                .fold(T::zero(), |acc, x| acc + x);
245            let mean = sum / T::from(lag_bins[i].len()).expect("conversion failed");
246            gamma_values[i] = mean;
247            valid_lags.push(lag_centers[i]);
248            valid_gammas.push(mean);
249        }
250    }
251
252    if valid_lags.is_empty() {
253        return Err(SpatialError::ValueError(
254            "No valid lags computed".to_string(),
255        ));
256    }
257
258    // Convert to arrays
259    let lags_array = Array1::from_vec(valid_lags);
260    let gamma_array = Array1::from_vec(valid_gammas);
261
262    Ok((lags_array, gamma_array))
263}
264
265/// Fit a theoretical variogram model to experimental data
266///
267/// Uses nonlinear least squares (Levenberg-Marquardt) to find the model
268/// parameters -- nugget, sill, range, and any model-specific extra
269/// parameter -- that minimize the sum of squared residuals between the
270/// model and the experimental variogram values. The heuristic estimates
271/// computed up front only seed the optimizer's initial guess; they are
272/// refined by the nonlinear solver before being returned.
273///
274/// # Arguments
275///
276/// * `lags` - Lag distances from experimental variogram
277/// * `gamma` - Variogram values at each lag
278/// * `model` - Type of variogram model to fit
279///
280/// # Returns
281///
282/// * Fitted variogram with optimized parameters
283///
284/// # Examples
285///
286/// ```
287/// use scirs2_core::ndarray::array;
288/// use scirs2_spatial::variogram::{fit_variogram, VariogramModel};
289///
290/// let lags = array![0.5, 1.0, 1.5, 2.0];
291/// let gamma = array![0.1, 0.4, 0.7, 0.9];
292///
293/// let fitted = fit_variogram(&lags, &gamma, VariogramModel::Spherical)
294///     .expect("Failed to fit");
295/// ```
296pub fn fit_variogram<T: Float>(
297    lags: &Array1<T>,
298    gamma: &Array1<T>,
299    model: VariogramModel,
300) -> SpatialResult<FittedVariogram<T>> {
301    if lags.len() != gamma.len() {
302        return Err(SpatialError::DimensionError(
303            "Lags and gamma must have same length".to_string(),
304        ));
305    }
306
307    if lags.is_empty() {
308        return Err(SpatialError::ValueError(
309            "Need at least one lag-gamma pair".to_string(),
310        ));
311    }
312
313    let zero = T::zero();
314    let one = T::one();
315
316    // Initial parameter estimates -- these only seed the nonlinear
317    // optimizer below; they are never returned as the "fitted" values.
318    let max_lag = lags
319        .iter()
320        .copied()
321        .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
322        .ok_or_else(|| SpatialError::ValueError("Failed to find max lag".to_string()))?;
323
324    let max_gamma = gamma
325        .iter()
326        .copied()
327        .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
328        .ok_or_else(|| SpatialError::ValueError("Failed to find max gamma".to_string()))?;
329
330    // Scale references used to build a strictly-positive lower bound for
331    // any parameter (range, sill, power, ...) that appears as a divisor or
332    // under `powf` in `FittedVariogram::evaluate` -- letting those hit
333    // exactly zero during optimization would produce NaN/Inf.
334    let safe_max_lag = if max_lag > zero { max_lag } else { one };
335    let safe_max_gamma = if max_gamma > zero { max_gamma } else { one };
336    let positive_floor =
337        (safe_max_lag.min(safe_max_gamma) * float_lit(1e-6, zero)).max(float_lit(1e-30, zero));
338
339    let initial_range = (max_lag * float_lit(0.7, zero)).max(positive_floor);
340    let initial_sill = (max_gamma * float_lit(0.9, zero)).max(positive_floor);
341    let initial_nugget = (gamma[0] * float_lit(0.1, zero)).max(zero);
342
343    let (mut params, lower_bounds) = initial_params(
344        model,
345        initial_nugget,
346        initial_sill,
347        initial_range,
348        positive_floor,
349    );
350
351    levenberg_marquardt_fit(model, lags, gamma, &mut params, &lower_bounds);
352
353    let mut fitted = params_to_fitted(model, &params);
354
355    // Compute R² for goodness of fit against the *converged* parameters.
356    let mean_gamma = gamma.sum() / T::from(gamma.len()).unwrap_or(one);
357    let mut ss_res = zero;
358    let mut ss_tot = zero;
359
360    for i in 0..lags.len() {
361        let predicted = fitted.evaluate(lags[i]);
362        let residual = gamma[i] - predicted;
363        ss_res = ss_res + residual * residual;
364
365        let deviation = gamma[i] - mean_gamma;
366        ss_tot = ss_tot + deviation * deviation;
367    }
368
369    fitted.r_squared = if ss_tot > zero {
370        one - ss_res / ss_tot
371    } else {
372        zero
373    };
374
375    Ok(fitted)
376}
377
378/// Convert an `f64` literal to `T`, falling back to `fallback` instead of
379/// panicking in the (practically unreachable for `f32`/`f64`) case the
380/// conversion fails.
381fn float_lit<T: Float>(x: f64, fallback: T) -> T {
382    T::from(x).unwrap_or(fallback)
383}
384
385/// Build the initial nonlinear-least-squares parameter vector for `model`
386/// from the heuristic starting estimates, along with the lower bound each
387/// parameter must stay at or above throughout optimization.
388///
389/// The parameter vector layout depends on the model: most models optimize
390/// `[nugget, sill, range]` directly, while `Linear` (which has no `range`
391/// in `evaluate`) optimizes `[nugget, slope]` and `Power` (whose exponent
392/// is the meaningful free parameter) optimizes `[nugget, scale, power]`.
393fn initial_params<T: Float>(
394    model: VariogramModel,
395    nugget0: T,
396    sill0: T,
397    range0: T,
398    positive_floor: T,
399) -> (Vec<T>, Vec<T>) {
400    let zero = T::zero();
401    match model {
402        VariogramModel::Linear => {
403            let slope0 = if range0 > zero { sill0 / range0 } else { sill0 };
404            (
405                vec![nugget0, slope0.max(positive_floor)],
406                vec![zero, positive_floor],
407            )
408        }
409        VariogramModel::Power => {
410            let power0 = float_lit(0.5, T::one());
411            (
412                vec![nugget0, sill0.max(positive_floor), power0],
413                vec![zero, positive_floor, positive_floor],
414            )
415        }
416        _ => (
417            vec![
418                nugget0,
419                sill0.max(positive_floor),
420                range0.max(positive_floor),
421            ],
422            vec![zero, positive_floor, positive_floor],
423        ),
424    }
425}
426
427/// Reconstruct a [`FittedVariogram`] from the internal optimizer parameter
428/// vector produced by [`initial_params`] / refined by
429/// [`levenberg_marquardt_fit`]. `r_squared` is left at zero here; the
430/// caller recomputes it once against the converged parameters.
431fn params_to_fitted<T: Float>(model: VariogramModel, params: &[T]) -> FittedVariogram<T> {
432    match model {
433        VariogramModel::Linear => FittedVariogram {
434            model,
435            range: T::one(),
436            sill: params[1],
437            nugget: params[0],
438            extra_params: vec![params[1]],
439            r_squared: T::zero(),
440        },
441        VariogramModel::Power => FittedVariogram {
442            model,
443            range: T::one(),
444            sill: params[1],
445            nugget: params[0],
446            extra_params: vec![params[2]],
447            r_squared: T::zero(),
448        },
449        _ => FittedVariogram {
450            model,
451            range: params[2],
452            sill: params[1],
453            nugget: params[0],
454            extra_params: vec![],
455            r_squared: T::zero(),
456        },
457    }
458}
459
460/// Refine `params` in place via Levenberg-Marquardt nonlinear least
461/// squares, minimizing `sum((gamma_i - model(lags_i; params))^2)` subject
462/// to each parameter staying at or above the matching entry in
463/// `lower_bounds`.
464///
465/// Uses a central-difference Jacobian of the model function (cheap here:
466/// at most 3 free parameters and typically a few dozen lag bins) together
467/// with the standard Marquardt diagonal-scaling damping strategy, and
468/// projects each accepted step back onto the box constraints.
469fn levenberg_marquardt_fit<T: Float>(
470    model: VariogramModel,
471    lags: &Array1<T>,
472    gamma: &Array1<T>,
473    params: &mut Vec<T>,
474    lower_bounds: &[T],
475) {
476    let n = params.len();
477    let m = lags.len();
478    if m == 0 || n == 0 {
479        return;
480    }
481
482    let zero = T::zero();
483    let one = T::one();
484
485    let evaluate_cost = |p: &[T]| -> (T, Vec<T>) {
486        let candidate = params_to_fitted(model, p);
487        let mut residuals = Vec::with_capacity(m);
488        let mut sse = zero;
489        for i in 0..m {
490            let r = gamma[i] - candidate.evaluate(lags[i]);
491            sse = sse + r * r;
492            residuals.push(r);
493        }
494        (sse, residuals)
495    };
496
497    let relative_step = float_lit(1e-6, zero);
498    let mut lambda = float_lit(1e-3, zero);
499    let lambda_up = float_lit(10.0, one);
500    let lambda_down = float_lit(0.1, one);
501    let min_lambda = float_lit(1e-12, zero);
502    let max_iters = 200;
503    let max_lambda_attempts = 30;
504
505    let (mut current_cost, mut residuals) = evaluate_cost(params);
506
507    for _ in 0..max_iters {
508        // Central-difference Jacobian: jac[i * n + j] = d(model_i)/d(param_j)
509        let mut jac = vec![zero; m * n];
510        for j in 0..n {
511            let base = params[j].abs();
512            let step = if base > zero {
513                base * relative_step
514            } else {
515                relative_step
516            };
517            let mut p_plus = params.clone();
518            let mut p_minus = params.clone();
519            p_plus[j] = p_plus[j] + step;
520            p_minus[j] = p_minus[j] - step;
521
522            let cand_plus = params_to_fitted(model, &p_plus);
523            let cand_minus = params_to_fitted(model, &p_minus);
524            let two_step = step + step;
525            for i in 0..m {
526                let f_plus = cand_plus.evaluate(lags[i]);
527                let f_minus = cand_minus.evaluate(lags[i]);
528                jac[i * n + j] = (f_plus - f_minus) / two_step;
529            }
530        }
531
532        // Normal equations: (JᵀJ + λ·diag(JᵀJ)) δ = Jᵀ·residuals
533        let mut jtj = vec![zero; n * n];
534        let mut jtr = vec![zero; n];
535        for i in 0..m {
536            for a in 0..n {
537                jtr[a] = jtr[a] + jac[i * n + a] * residuals[i];
538                for b in 0..n {
539                    jtj[a * n + b] = jtj[a * n + b] + jac[i * n + a] * jac[i * n + b];
540                }
541            }
542        }
543
544        let mut attempt_lambda = lambda;
545        let mut improved = false;
546
547        for _ in 0..max_lambda_attempts {
548            let mut damped = jtj.clone();
549            for d in 0..n {
550                let diag = jtj[d * n + d];
551                damped[d * n + d] = if diag > zero {
552                    diag * (one + attempt_lambda)
553                } else {
554                    attempt_lambda.max(min_lambda)
555                };
556            }
557
558            if let Some(delta) = solve_linear_system(&damped, &jtr, n) {
559                let mut candidate_params = params.clone();
560                for (k, slot) in candidate_params.iter_mut().enumerate() {
561                    let updated = *slot + delta[k];
562                    *slot = if updated < lower_bounds[k] {
563                        lower_bounds[k]
564                    } else {
565                        updated
566                    };
567                }
568
569                let (candidate_cost, candidate_residuals) = evaluate_cost(&candidate_params);
570                if candidate_cost < current_cost {
571                    *params = candidate_params;
572                    residuals = candidate_residuals;
573                    current_cost = candidate_cost;
574                    lambda = (attempt_lambda * lambda_down).max(min_lambda);
575                    improved = true;
576                    break;
577                }
578            }
579
580            attempt_lambda = attempt_lambda * lambda_up;
581        }
582
583        if !improved {
584            // No damping level improved the fit any further: a stationary
585            // point (up to numerical precision) has been reached.
586            break;
587        }
588    }
589}
590
591/// Solve the dense `n x n` linear system `a * x = b` via Gaussian
592/// elimination with partial pivoting. `a` is row-major. Returns `None` if
593/// the system is numerically singular.
594fn solve_linear_system<T: Float>(a: &[T], b: &[T], n: usize) -> Option<Vec<T>> {
595    let zero = T::zero();
596    let pivot_floor = float_lit(1e-300, zero);
597    let mut aug = vec![zero; n * (n + 1)];
598    for i in 0..n {
599        aug[i * (n + 1)..i * (n + 1) + n].copy_from_slice(&a[i * n..i * n + n]);
600        aug[i * (n + 1) + n] = b[i];
601    }
602
603    for col in 0..n {
604        let mut pivot_row = col;
605        let mut pivot_val = aug[col * (n + 1) + col].abs();
606        for row in (col + 1)..n {
607            let val = aug[row * (n + 1) + col].abs();
608            if val > pivot_val {
609                pivot_val = val;
610                pivot_row = row;
611            }
612        }
613        if pivot_val <= pivot_floor {
614            return None;
615        }
616        if pivot_row != col {
617            for k in 0..(n + 1) {
618                aug.swap(col * (n + 1) + k, pivot_row * (n + 1) + k);
619            }
620        }
621
622        let pivot = aug[col * (n + 1) + col];
623        for row in (col + 1)..n {
624            let factor = aug[row * (n + 1) + col] / pivot;
625            if factor.is_zero() {
626                continue;
627            }
628            for k in col..(n + 1) {
629                let sub = factor * aug[col * (n + 1) + k];
630                aug[row * (n + 1) + k] = aug[row * (n + 1) + k] - sub;
631            }
632        }
633    }
634
635    let mut x = vec![zero; n];
636    for row in (0..n).rev() {
637        let mut sum = aug[row * (n + 1) + n];
638        for (col, &xc) in x.iter().enumerate().take(n).skip(row + 1) {
639            sum = sum - aug[row * (n + 1) + col] * xc;
640        }
641        let diag = aug[row * (n + 1) + row];
642        if diag.abs() <= pivot_floor {
643            return None;
644        }
645        x[row] = sum / diag;
646    }
647
648    Some(x)
649}
650
651/// Compute directional (anisotropic) variogram
652///
653/// Computes experimental variogram for a specific direction, useful for
654/// detecting directional trends in spatial correlation.
655///
656/// # Arguments
657///
658/// * `coordinates` - Spatial coordinates (must be 2D)
659/// * `values` - Observed values
660/// * `direction` - Direction angle in radians (0 = East, π/2 = North)
661/// * `tolerance` - Angular tolerance in radians
662/// * `n_lags` - Number of lag bins
663///
664/// # Returns
665///
666/// * Tuple of (lag distances, variogram values)
667pub fn directional_variogram<T: Float>(
668    coordinates: &ArrayView2<T>,
669    values: &ArrayView1<T>,
670    direction: T,
671    tolerance: T,
672    n_lags: usize,
673) -> SpatialResult<(Array1<T>, Array1<T>)> {
674    let n = coordinates.shape()[0];
675
676    if coordinates.shape()[1] != 2 {
677        return Err(SpatialError::DimensionError(
678            "Directional variogram requires 2D coordinates".to_string(),
679        ));
680    }
681
682    if n != values.len() {
683        return Err(SpatialError::DimensionError(
684            "Number of coordinates must match number of values".to_string(),
685        ));
686    }
687
688    // Compute pairwise distances and angles
689    let mut pairs = Vec::new();
690    for i in 0..n {
691        for j in (i + 1)..n {
692            let dx = coordinates[[j, 0]] - coordinates[[i, 0]];
693            let dy = coordinates[[j, 1]] - coordinates[[i, 1]];
694
695            let distance = (dx * dx + dy * dy).sqrt();
696            let angle = dy.atan2(dx); // atan2(dy, dx) gives angle from East
697
698            // Check if angle matches direction within tolerance
699            let angle_diff = (angle - direction).abs();
700            let pi_t = T::from(PI).expect("conversion failed");
701            let angle_diff_wrapped = if angle_diff > pi_t {
702                (T::one() + T::one()) * pi_t - angle_diff
703            } else {
704                angle_diff
705            };
706
707            if angle_diff_wrapped <= tolerance {
708                let value_diff = values[i] - values[j];
709                let gamma = value_diff * value_diff / (T::one() + T::one());
710                pairs.push((distance, gamma));
711            }
712        }
713    }
714
715    if pairs.is_empty() {
716        return Err(SpatialError::ValueError(
717            "No pairs found in specified direction".to_string(),
718        ));
719    }
720
721    // Rest of variogram computation similar to experimental_variogram
722    // (simplified for brevity)
723    let max_distance = pairs
724        .iter()
725        .map(|(d, _)| *d)
726        .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
727        .ok_or_else(|| SpatialError::ValueError("Failed to find max distance".to_string()))?;
728
729    let lag_size = max_distance / T::from(n_lags).expect("conversion failed");
730    let mut lag_bins: Vec<Vec<T>> = vec![Vec::new(); n_lags];
731    let mut lag_centers = Array1::zeros(n_lags);
732
733    for i in 0..n_lags {
734        let lag_center = lag_size
735            * (T::from(i).expect("conversion failed") + T::from(0.5).expect("conversion failed"));
736        lag_centers[i] = lag_center;
737
738        for &(distance, gamma) in &pairs {
739            let lag_tolerance = lag_size / (T::one() + T::one());
740            if (distance - lag_center).abs() <= lag_tolerance {
741                lag_bins[i].push(gamma);
742            }
743        }
744    }
745
746    let mut valid_lags = Vec::new();
747    let mut valid_gammas = Vec::new();
748
749    for i in 0..n_lags {
750        if !lag_bins[i].is_empty() {
751            let sum: T = lag_bins[i]
752                .iter()
753                .copied()
754                .fold(T::zero(), |acc, x| acc + x);
755            let mean = sum / T::from(lag_bins[i].len()).expect("conversion failed");
756            valid_lags.push(lag_centers[i]);
757            valid_gammas.push(mean);
758        }
759    }
760
761    Ok((Array1::from_vec(valid_lags), Array1::from_vec(valid_gammas)))
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use approx::assert_relative_eq;
768    use scirs2_core::ndarray::array;
769
770    #[test]
771    fn test_experimental_variogram() {
772        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
773        let values = array![1.0, 2.0, 1.5, 2.5];
774
775        let result = experimental_variogram(&coords.view(), &values.view(), 5, None);
776        assert!(result.is_ok());
777
778        let (lags, gamma) = result.expect("computation failed");
779        assert!(!lags.is_empty());
780        assert_eq!(lags.len(), gamma.len());
781
782        // All gamma values should be non-negative
783        for &g in gamma.iter() {
784            assert!(g >= 0.0);
785        }
786    }
787
788    #[test]
789    fn test_fit_spherical_variogram() {
790        let lags = array![0.5, 1.0, 1.5, 2.0, 2.5];
791        let gamma = array![0.1, 0.3, 0.6, 0.85, 0.95];
792
793        let fitted = fit_variogram(&lags, &gamma, VariogramModel::Spherical);
794        assert!(fitted.is_ok());
795
796        let model = fitted.expect("fitting failed");
797        assert!(model.range > 0.0);
798        assert!(model.sill > 0.0);
799        assert!(model.nugget >= 0.0);
800    }
801
802    #[test]
803    fn test_fit_exponential_variogram() {
804        let lags = array![0.5, 1.0, 1.5, 2.0, 2.5];
805        let gamma = array![0.2, 0.4, 0.6, 0.75, 0.85];
806
807        let fitted = fit_variogram(&lags, &gamma, VariogramModel::Exponential);
808        assert!(fitted.is_ok());
809
810        let model = fitted.expect("fitting failed");
811        assert!(model.range > 0.0);
812        assert!(model.sill > 0.0);
813    }
814
815    /// Regression test for the fitting stub: `fit_variogram` used to return
816    /// `range = max_lag * 0.7`, `sill = max_gamma * 0.9`, and
817    /// `nugget = gamma[0] * 0.1` verbatim, completely ignoring the shape of
818    /// the (non-constant) input data. Here the "experimental" data is
819    /// generated exactly from a known spherical model, so a real
820    /// least-squares fit must recover parameters close to the ground
821    /// truth -- while the old heuristic would have returned range=21.0
822    /// (vs. true 12.0) and nugget=~0.047 (vs. true 0.5).
823    #[test]
824    fn test_fit_variogram_recovers_known_parameters() {
825        let true_model = FittedVariogram {
826            model: VariogramModel::Spherical,
827            range: 12.0,
828            sill: 4.0,
829            nugget: 0.5,
830            extra_params: vec![],
831            r_squared: 1.0,
832        };
833
834        let lags = array![
835            0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 15.0, 18.0, 22.0, 30.0
836        ];
837        let gamma = lags.mapv(|h| true_model.evaluate(h));
838
839        let fitted = fit_variogram(&lags, &gamma, VariogramModel::Spherical)
840            .expect("fit should succeed on noiseless synthetic data");
841
842        // The old heuristic-only stub would give range = max_lag*0.7 = 21.0,
843        // sill = max_gamma*0.9, nugget = gamma[0]*0.1 -- all far from the
844        // ground truth used to generate this data.
845        assert_relative_eq!(fitted.range, 12.0, epsilon = 0.3);
846        assert_relative_eq!(fitted.sill, 4.0, epsilon = 0.1);
847        assert_relative_eq!(fitted.nugget, 0.5, epsilon = 0.1);
848        assert!(
849            fitted.r_squared > 0.999,
850            "expected near-perfect fit on noiseless data, got r_squared = {}",
851            fitted.r_squared
852        );
853    }
854
855    #[test]
856    fn test_variogram_evaluate() {
857        let fitted = FittedVariogram {
858            model: VariogramModel::Spherical,
859            range: 2.0,
860            sill: 1.0,
861            nugget: 0.1,
862            extra_params: vec![],
863            r_squared: 0.95,
864        };
865
866        // At zero distance, should be close to nugget
867        let gamma_0 = fitted.evaluate(0.0);
868        assert_relative_eq!(gamma_0, 0.1, epsilon = 0.01);
869
870        // At range, should approach nugget + sill
871        let gamma_range = fitted.evaluate(2.0);
872        assert!(gamma_range >= 1.0);
873        assert!(gamma_range <= 1.2);
874
875        // Beyond range, should be nugget + sill
876        let gamma_beyond = fitted.evaluate(5.0);
877        assert_relative_eq!(gamma_beyond, 1.1, epsilon = 0.01);
878    }
879
880    #[test]
881    fn test_directional_variogram() {
882        let coords = array![
883            [0.0, 0.0],
884            [1.0, 0.0],
885            [2.0, 0.0],
886            [0.0, 1.0],
887            [1.0, 1.0],
888            [2.0, 1.0]
889        ];
890        let values = array![1.0, 1.5, 2.0, 1.2, 1.7, 2.2];
891
892        // East direction (0 radians)
893        let result = directional_variogram(
894            &coords.view(),
895            &values.view(),
896            0.0,
897            std::f64::consts::PI / 4.0, // 45 degree tolerance
898            5,
899        );
900
901        assert!(result.is_ok());
902        let (lags, gamma) = result.expect("computation failed");
903        assert!(!lags.is_empty());
904    }
905
906    #[test]
907    fn test_variogram_models() {
908        let models = vec![
909            VariogramModel::Spherical,
910            VariogramModel::Exponential,
911            VariogramModel::Gaussian,
912            VariogramModel::Linear,
913        ];
914
915        for model in models {
916            let fitted = FittedVariogram {
917                model,
918                range: 1.0,
919                sill: 1.0,
920                nugget: 0.0,
921                extra_params: vec![],
922                r_squared: 0.0,
923            };
924
925            // All models should be monotonically increasing
926            let gamma_1 = fitted.evaluate(0.5);
927            let gamma_2 = fitted.evaluate(1.0);
928            assert!(gamma_2 >= gamma_1);
929        }
930    }
931}