#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VariogramModel {
Nugget,
Spherical,
Exponential,
Gaussian,
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Variogram {
pub model: VariogramModel,
pub nugget: f64,
pub sill: f64,
pub range: f64,
}
impl Variogram {
pub fn new(
model: VariogramModel,
nugget: f64,
sill: f64,
range: f64,
) -> crate::foundation::Result<Variogram> {
use crate::foundation::AlgoError;
if !(nugget.is_finite() && sill.is_finite() && range.is_finite()) {
return Err(AlgoError::InvalidArgument(
"variogram: nugget, sill and range must be finite".to_string(),
));
}
if nugget < 0.0 || sill < 0.0 {
return Err(AlgoError::InvalidArgument(
"variogram: nugget and sill must be non-negative".to_string(),
));
}
let effective_sill = match model {
VariogramModel::Nugget => nugget,
_ => nugget + sill,
};
if effective_sill <= 0.0 {
return Err(AlgoError::InvalidArgument(
match model {
VariogramModel::Nugget => {
"variogram: the Nugget model uses only its nugget (sill is ignored), so nugget must be positive"
}
_ => "variogram: total sill (nugget + sill) must be positive",
}
.to_string(),
));
}
if range <= 0.0 {
return Err(AlgoError::InvalidArgument(
"variogram: range must be positive".to_string(),
));
}
Ok(Variogram {
model,
nugget,
sill,
range,
})
}
pub fn total_sill(&self) -> f64 {
self.nugget + self.sill
}
pub fn gamma(&self, h: f64) -> f64 {
let h = h.abs();
if h == 0.0 {
return 0.0;
}
let structured = match self.model {
VariogramModel::Nugget => 0.0,
VariogramModel::Spherical => {
let r = h / self.range;
if r >= 1.0 {
self.sill
} else {
self.sill * (1.5 * r - 0.5 * r * r * r)
}
}
VariogramModel::Exponential => self.sill * (1.0 - (-3.0 * h / self.range).exp()),
VariogramModel::Gaussian => {
let r = h / self.range;
self.sill * (1.0 - (-3.0 * r * r).exp())
}
};
self.nugget + structured
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AnisotropicVariogram {
pub model: VariogramModel,
pub nugget: f64,
pub sill: f64,
pub major: f64,
pub minor: f64,
pub vertical: f64,
pub azimuth: f64,
}
impl AnisotropicVariogram {
pub fn new(
model: VariogramModel,
nugget: f64,
sill: f64,
major: f64,
minor: f64,
vertical: f64,
azimuth: f64,
) -> crate::foundation::Result<AnisotropicVariogram> {
use crate::foundation::AlgoError;
if ![nugget, sill, major, minor, vertical, azimuth]
.into_iter()
.all(f64::is_finite)
{
return Err(AlgoError::InvalidArgument(
"anisotropic variogram: nugget, sill, ranges and azimuth must be finite"
.to_string(),
));
}
if nugget < 0.0 || sill < 0.0 {
return Err(AlgoError::InvalidArgument(
"anisotropic variogram: nugget and sill must be non-negative".to_string(),
));
}
let effective_sill = match model {
VariogramModel::Nugget => nugget,
_ => nugget + sill,
};
if effective_sill <= 0.0 {
return Err(AlgoError::InvalidArgument(
match model {
VariogramModel::Nugget => {
"anisotropic variogram: the Nugget model uses only its nugget (sill is ignored), so nugget must be positive"
}
_ => "anisotropic variogram: total sill (nugget + sill) must be positive",
}
.to_string(),
));
}
if major <= 0.0 || minor <= 0.0 || vertical <= 0.0 {
return Err(AlgoError::InvalidArgument(
"anisotropic variogram: major, minor and vertical ranges must be positive"
.to_string(),
));
}
Ok(AnisotropicVariogram {
model,
nugget,
sill,
major,
minor,
vertical,
azimuth: azimuth.rem_euclid(360.0),
})
}
pub fn isotropic(
model: VariogramModel,
nugget: f64,
sill: f64,
range: f64,
) -> crate::foundation::Result<AnisotropicVariogram> {
Self::new(model, nugget, sill, range, range, range, 0.0)
}
pub fn total_sill(&self) -> f64 {
self.nugget + self.sill
}
pub fn anisotropic_distance(&self, dx: f64, dy: f64, dz: f64) -> f64 {
let az = self.azimuth.to_radians();
let (sin_az, cos_az) = az.sin_cos();
let h_major = dx * sin_az + dy * cos_az;
let h_minor = dx * cos_az - dy * sin_az;
let minor_scaled = h_minor * self.major / self.minor;
let vertical_scaled = dz * self.major / self.vertical;
(h_major * h_major + minor_scaled * minor_scaled + vertical_scaled * vertical_scaled).sqrt()
}
pub fn gamma_offset(&self, dx: f64, dy: f64, dz: f64) -> f64 {
let h = self.anisotropic_distance(dx, dy, dz);
Variogram {
model: self.model,
nugget: self.nugget,
sill: self.sill,
range: self.major,
}
.gamma(h)
}
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SpatialVariogram {
Isotropic(Variogram),
Anisotropic(AnisotropicVariogram),
}
impl SpatialVariogram {
pub fn total_sill(&self) -> f64 {
match self {
SpatialVariogram::Isotropic(v) => v.total_sill(),
SpatialVariogram::Anisotropic(v) => v.total_sill(),
}
}
pub fn gamma_offset_2d(&self, dx: f64, dy: f64) -> f64 {
match self {
SpatialVariogram::Isotropic(v) => v.gamma((dx * dx + dy * dy).sqrt()),
SpatialVariogram::Anisotropic(v) => v.gamma_offset(dx, dy, 0.0),
}
}
pub fn gamma_between_2d(&self, a: [f64; 2], b: [f64; 2]) -> f64 {
self.gamma_offset_2d(a[0] - b[0], a[1] - b[1])
}
pub fn gamma(&self, h: f64) -> f64 {
match self {
SpatialVariogram::Isotropic(v) => v.gamma(h),
SpatialVariogram::Anisotropic(v) => v.gamma_offset(h, 0.0, 0.0),
}
}
}
impl From<Variogram> for SpatialVariogram {
fn from(value: Variogram) -> Self {
SpatialVariogram::Isotropic(value)
}
}
impl From<&Variogram> for SpatialVariogram {
fn from(value: &Variogram) -> Self {
SpatialVariogram::Isotropic(*value)
}
}
impl From<AnisotropicVariogram> for SpatialVariogram {
fn from(value: AnisotropicVariogram) -> Self {
SpatialVariogram::Anisotropic(value)
}
}
impl From<&AnisotropicVariogram> for SpatialVariogram {
fn from(value: &AnisotropicVariogram) -> Self {
SpatialVariogram::Anisotropic(*value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_parameters() {
assert!(Variogram::new(VariogramModel::Spherical, -1.0, 1.0, 10.0).is_err());
assert!(Variogram::new(VariogramModel::Spherical, 0.0, -1.0, 10.0).is_err());
assert!(Variogram::new(VariogramModel::Spherical, 0.0, 0.0, 10.0).is_err()); assert!(Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 0.0).is_err()); assert!(Variogram::new(VariogramModel::Spherical, 0.0, 1.0, f64::NAN).is_err());
assert!(Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 10.0).is_ok());
assert!(matches!(
Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 0.0),
Err(crate::foundation::AlgoError::InvalidArgument(_))
));
}
#[test]
fn nugget_model_rejects_zero_nugget() {
let err = Variogram::new(VariogramModel::Nugget, 0.0, 5.0, 10.0);
assert!(err.is_err(), "zero-nugget Nugget must fail at construction");
assert!(Variogram::new(VariogramModel::Nugget, 1.0, 0.0, 10.0).is_ok());
assert!(Variogram::new(VariogramModel::Spherical, 0.0, 1.0, 10.0).is_ok());
}
#[test]
fn gamma_is_zero_at_origin() {
for model in [
VariogramModel::Nugget,
VariogramModel::Spherical,
VariogramModel::Exponential,
VariogramModel::Gaussian,
] {
let v = Variogram::new(model, 0.5, 2.0, 10.0).unwrap();
assert_eq!(v.gamma(0.0), 0.0, "{model:?}");
}
}
#[test]
fn nugget_jumps_then_flat() {
let v = Variogram::new(VariogramModel::Nugget, 3.0, 5.0, 10.0).unwrap();
assert_eq!(v.gamma(0.0), 0.0);
assert_eq!(v.gamma(0.01), 3.0);
assert_eq!(v.gamma(1e6), 3.0);
}
#[test]
fn spherical_reaches_sill_exactly_at_range() {
let v = Variogram::new(VariogramModel::Spherical, 0.0, 4.0, 10.0).unwrap();
assert_relative_eq!(v.gamma(10.0), 4.0, epsilon = 1e-12);
assert_relative_eq!(v.gamma(15.0), 4.0, epsilon = 1e-12);
assert_relative_eq!(v.gamma(5.0), 4.0 * 0.687_5, epsilon = 1e-12);
}
#[test]
fn exponential_and_gaussian_hit_95pct_at_practical_range() {
let expected = 1.0 - (-3.0_f64).exp();
let e = Variogram::new(VariogramModel::Exponential, 0.0, 1.0, 20.0).unwrap();
assert_relative_eq!(e.gamma(20.0), expected, epsilon = 1e-12);
let g = Variogram::new(VariogramModel::Gaussian, 0.0, 1.0, 20.0).unwrap();
assert_relative_eq!(g.gamma(20.0), expected, epsilon = 1e-12);
}
#[test]
fn monotone_non_decreasing_and_bounded_by_total_sill() {
let v = Variogram::new(VariogramModel::Exponential, 1.0, 3.0, 15.0).unwrap();
let mut prev = v.gamma(1e-9);
let mut h = 0.0;
while h < 100.0 {
h += 0.5;
let g = v.gamma(h);
assert!(g >= prev - 1e-12, "not monotone at h={h}: {g} < {prev}");
assert!(g <= v.total_sill() + 1e-12, "exceeds sill at h={h}: {g}");
prev = g;
}
}
#[test]
fn gamma_is_symmetric_in_lag_sign() {
let v = Variogram::new(VariogramModel::Gaussian, 0.2, 1.0, 5.0).unwrap();
assert_eq!(v.gamma(3.0), v.gamma(-3.0));
}
#[test]
fn anisotropic_rejects_invalid_parameters_and_normalizes_azimuth() {
assert!(AnisotropicVariogram::new(
VariogramModel::Spherical,
0.0,
1.0,
0.0,
10.0,
5.0,
0.0
)
.is_err());
assert!(AnisotropicVariogram::new(
VariogramModel::Spherical,
0.0,
1.0,
20.0,
-10.0,
5.0,
0.0
)
.is_err());
assert!(AnisotropicVariogram::new(
VariogramModel::Spherical,
0.0,
1.0,
20.0,
10.0,
f64::NAN,
0.0
)
.is_err());
assert!(AnisotropicVariogram::new(
VariogramModel::Spherical,
0.0,
0.0,
20.0,
10.0,
5.0,
0.0
)
.is_err());
let v =
AnisotropicVariogram::new(VariogramModel::Spherical, 0.05, 1.0, 20.0, 10.0, 5.0, 395.0)
.unwrap();
assert_relative_eq!(v.azimuth, 35.0, epsilon = 1e-12);
}
#[test]
fn isotropic_anisotropic_distance_matches_scalar_distance() {
let iso = Variogram::new(VariogramModel::Spherical, 0.05, 1.0, 25.0).unwrap();
let aniso =
AnisotropicVariogram::isotropic(VariogramModel::Spherical, 0.05, 1.0, 25.0).unwrap();
let d = (3.0_f64 * 3.0 + 4.0 * 4.0 + 12.0 * 12.0).sqrt();
assert_relative_eq!(
aniso.anisotropic_distance(3.0, 4.0, 12.0),
d,
epsilon = 1e-12
);
assert_relative_eq!(
aniso.gamma_offset(3.0, 4.0, 12.0),
iso.gamma(d),
epsilon = 1e-12
);
}
#[test]
fn anisotropic_continuity_is_longer_along_major_after_rotation() {
let v =
AnisotropicVariogram::new(VariogramModel::Spherical, 0.0, 1.0, 100.0, 25.0, 10.0, 90.0)
.unwrap();
let major_gamma = v.gamma_offset(30.0, 0.0, 0.0);
let minor_gamma = v.gamma_offset(0.0, 30.0, 0.0);
assert!(
major_gamma < minor_gamma,
"same lag should be more continuous along major: {major_gamma} !< {minor_gamma}"
);
}
}