use crate::foundation::{AlgoError, Lattice, Result};
use crate::geostat::{sgs_unconditional, NormalScore};
use crate::gridding::kriging::Variogram;
use ndarray::Array2;
use statrs::distribution::{ContinuousCDF, Normal as StatrsNormal};
#[derive(Debug, Clone)]
pub struct NoiseSpec {
pub variance: f64,
pub variogram: Variogram,
}
impl NoiseSpec {
pub fn new(variance: f64, variogram: Variogram) -> Result<NoiseSpec> {
if !(variance.is_finite() && variance >= 0.0) {
return Err(AlgoError::InvalidArgument(
"NoiseSpec: variance must be finite and >= 0".to_string(),
));
}
Ok(NoiseSpec {
variance,
variogram,
})
}
}
fn search_params(lattice: &Lattice, variogram: &Variogram) -> (usize, f64) {
let cell = lattice
.xinc
.abs()
.max(lattice.yinc.abs())
.max(f64::MIN_POSITIVE);
let radius = (variogram.range * 1.5).max(cell * 4.0);
(24, radius)
}
pub fn synth_dome_surface(
lattice: &Lattice,
relief: f64,
aspect: f64,
tilt: f64,
noise: &NoiseSpec,
seed: u64,
) -> Result<Array2<f64>> {
if !relief.is_finite() || !tilt.is_finite() {
return Err(AlgoError::InvalidArgument(
"synth_dome_surface: relief and tilt must be finite".to_string(),
));
}
if !(aspect.is_finite() && aspect > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_dome_surface: aspect must be finite and > 0".to_string(),
));
}
let (ncol, nrow) = (lattice.ncol, lattice.nrow);
let bb = lattice.bbox();
let (cx, cy) = (0.5 * (bb.xmin + bb.xmax), 0.5 * (bb.ymin + bb.ymax));
let hx = 0.5 * (bb.xmax - bb.xmin);
let hy = 0.5 * (bb.ymax - bb.ymin);
let sx = (hx / 2.0 * aspect.sqrt()).max(f64::MIN_POSITIVE);
let sy = (hy / 2.0 / aspect.sqrt()).max(f64::MIN_POSITIVE);
let xspan = (bb.xmax - bb.xmin).max(f64::MIN_POSITIVE);
let noise_field = if noise.variance > 0.0 {
let (mn, r) = search_params(lattice, &noise.variogram);
Some(sgs_unconditional(
lattice,
0.0,
noise.variance,
&noise.variogram,
mn,
r,
seed,
)?)
} else {
None
};
let mut out = Array2::from_elem((ncol, nrow), 0.0);
for i in 0..ncol {
for j in 0..nrow {
let (x, y) = lattice.node_xy(i, j);
let dx = (x - cx) / sx;
let dy = (y - cy) / sy;
let dome = relief * (-0.5 * (dx * dx + dy * dy)).exp();
let plane = tilt * ((x - bb.xmin) / xspan - 0.5);
let eps = noise_field.as_ref().map(|f| f[[i, j]]).unwrap_or(0.0);
out[[i, j]] = dome + plane + eps;
}
}
Ok(out)
}
pub fn synth_isochore(
lattice: &Lattice,
mean_thickness: f64,
variability: f64,
variogram: &Variogram,
seed: u64,
) -> Result<Array2<f64>> {
if !mean_thickness.is_finite() {
return Err(AlgoError::InvalidArgument(
"synth_isochore: mean_thickness must be finite".to_string(),
));
}
if !(variability.is_finite() && variability >= 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_isochore: variability must be finite and >= 0".to_string(),
));
}
let (mn, r) = search_params(lattice, variogram);
let field = sgs_unconditional(
lattice,
mean_thickness,
variability * variability,
variogram,
mn,
r,
seed,
)?;
Ok(field.mapv(|v| v.max(0.0)))
}
pub fn synth_trend_map(
lattice: &Lattice,
variogram: &Variogram,
seed: u64,
correlate_with: Option<(&Array2<f64>, f64)>,
) -> Result<Array2<f64>> {
let (ncol, nrow) = (lattice.ncol, lattice.nrow);
let (mn, r) = search_params(lattice, variogram);
let g = sgs_unconditional(lattice, 0.0, 1.0, variogram, mn, r, seed)?;
let gaussian = match correlate_with {
None => g,
Some((field, rho)) => {
if !(rho.is_finite()) || !(-1.0..=1.0).contains(&rho) {
return Err(AlgoError::InvalidArgument(
"synth_trend_map: rho must be in [-1, 1]".to_string(),
));
}
if field.dim() != (ncol, nrow) {
return Err(AlgoError::InvalidArgument(
"synth_trend_map: correlate_with field shape must match the lattice"
.to_string(),
));
}
let vals: Vec<f64> = field.iter().cloned().filter(|v| v.is_finite()).collect();
let ns = NormalScore::fit(&vals)?;
let w = (1.0 - rho * rho).max(0.0).sqrt();
Array2::from_shape_fn((ncol, nrow), |(i, j)| {
let y = if field[[i, j]].is_finite() {
ns.forward(field[[i, j]])
} else {
0.0
};
rho * y + w * g[[i, j]]
})
}
};
let snorm = StatrsNormal::new(0.0, 1.0).expect("standard normal");
Ok(gaussian.mapv(|z| snorm.cdf(z)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gridding::kriging::VariogramModel;
use crate::stats::mean;
fn vg(range: f64) -> Variogram {
Variogram::new(VariogramModel::Spherical, 0.0, 1.0, range).unwrap()
}
#[test]
fn dome_has_interior_crest() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 40, 40);
let noise = NoiseSpec::new(0.0, vg(100.0)).unwrap();
let s = synth_dome_surface(&lat, 100.0, 1.0, 0.0, &noise, 1).unwrap();
let mut best = (0usize, 0usize, f64::NEG_INFINITY);
for i in 0..40 {
for j in 0..40 {
if s[[i, j]] > best.2 {
best = (i, j, s[[i, j]]);
}
}
}
assert!(
best.0 > 5 && best.0 < 34 && best.1 > 5 && best.1 < 34,
"crest at boundary: {best:?}"
);
assert!(best.2 > 80.0, "crest {} below relief", best.2);
assert!(s[[0, 0]] < 30.0, "corner {} not a flank", s[[0, 0]]);
}
#[test]
fn dome_aspect_elongates() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 41, 41);
let noise = NoiseSpec::new(0.0, vg(100.0)).unwrap();
let s = synth_dome_surface(&lat, 100.0, 3.0, 0.0, &noise, 1).unwrap();
let mid = 20;
let along_x = s[[mid + 8, mid]];
let along_y = s[[mid, mid + 8]];
assert!(
along_x > along_y,
"x-elongation not visible: {along_x} vs {along_y}"
);
}
#[test]
fn dome_bit_reproducible_with_noise() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 30, 30);
let noise = NoiseSpec::new(25.0, vg(120.0)).unwrap();
let a = synth_dome_surface(&lat, 80.0, 1.5, 40.0, &noise, 7).unwrap();
let b = synth_dome_surface(&lat, 80.0, 1.5, 40.0, &noise, 7).unwrap();
assert_eq!(a, b);
}
#[test]
fn isochore_is_nonnegative_and_on_mean() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 40, 40);
let f = synth_isochore(&lat, 20.0, 5.0, &vg(120.0), 3).unwrap();
assert!(f.iter().all(|&v| v >= 0.0), "negative thickness");
let flat: Vec<f64> = f.iter().cloned().collect();
assert!((mean(&flat).unwrap() - 20.0).abs() < 4.0, "mean off");
}
#[test]
fn isochore_zero_variability_is_constant() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 10, 10);
let f = synth_isochore(&lat, 12.0, 0.0, &vg(50.0), 1).unwrap();
assert!(f.iter().all(|&v| v == 12.0));
}
#[test]
fn trend_map_is_in_unit_interval() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 30, 30);
let t = synth_trend_map(&lat, &vg(120.0), 5, None).unwrap();
assert!(t.iter().all(|&v| (0.0..=1.0).contains(&v)));
}
#[test]
fn trend_map_recovers_target_correlation() {
let lat = Lattice::regular(0.0, 0.0, 10.0, 10.0, 50, 50);
let base = sgs_unconditional(&lat, 0.5, 0.02, &vg(150.0), 24, 200.0, 11).unwrap();
for &rho in &[0.0_f64, 0.5, 0.8] {
let trend = synth_trend_map(&lat, &vg(150.0), 22, Some((&base, rho))).unwrap();
let bvals: Vec<f64> = base.iter().cloned().collect();
let nb = NormalScore::fit(&bvals).unwrap();
let tvals: Vec<f64> = trend.iter().cloned().collect();
let nt = NormalScore::fit(&tvals).unwrap();
let ys: Vec<f64> = base.iter().map(|&v| nb.forward(v)).collect();
let xs: Vec<f64> = trend.iter().map(|&v| nt.forward(v)).collect();
let r = pearson(&xs, &ys);
assert!((r - rho).abs() < 0.12, "realized corr {r} vs target {rho}");
}
}
fn pearson(a: &[f64], b: &[f64]) -> f64 {
let n = a.len() as f64;
let ma = a.iter().sum::<f64>() / n;
let mb = b.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut va = 0.0;
let mut vb = 0.0;
for i in 0..a.len() {
cov += (a[i] - ma) * (b[i] - mb);
va += (a[i] - ma).powi(2);
vb += (b[i] - mb).powi(2);
}
cov / (va.sqrt() * vb.sqrt())
}
}