use crate::error::StaticError;
use ndarray::Array2;
use petektools::{resample, Lattice, ResampleMethod};
#[derive(Debug, Clone, Copy, PartialEq)]
struct Georef {
origin_x: f64,
origin_y: f64,
node_dx: f64,
node_dy: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrendSurface {
ncol: usize,
nrow: usize,
values: Vec<f64>,
apply_porosity: bool,
georef: Option<Georef>,
}
impl TrendSurface {
pub fn new(ncol: usize, nrow: usize, values: Vec<f64>) -> Result<Self, StaticError> {
if ncol == 0 || nrow == 0 {
return Err(StaticError::InvalidInput(format!(
"trend lattice must be non-empty, got {ncol}x{nrow}"
)));
}
if values.len() != ncol * nrow {
return Err(StaticError::InvalidInput(format!(
"trend has {} values, expected {}",
values.len(),
ncol * nrow
)));
}
if let Some(bad) = values.iter().find(|v| v.is_finite() && **v < 0.0) {
return Err(StaticError::InvalidInput(format!(
"trend multiplier must be non-negative, got {bad}"
)));
}
Ok(Self {
ncol,
nrow,
values,
apply_porosity: false,
georef: None,
})
}
#[must_use]
pub fn with_porosity(mut self) -> Self {
self.apply_porosity = true;
self
}
#[must_use]
pub fn with_georef(mut self, origin_x: f64, origin_y: f64, node_dx: f64, node_dy: f64) -> Self {
if node_dx.is_finite() && node_dx > 0.0 && node_dy.is_finite() && node_dy > 0.0 {
self.georef = Some(Georef {
origin_x,
origin_y,
node_dx,
node_dy,
});
}
self
}
#[must_use]
pub fn is_georeferenced(&self) -> bool {
self.georef.is_some()
}
#[must_use]
pub fn applies_to_porosity(&self) -> bool {
self.apply_porosity
}
pub(crate) fn resample_to(&self, target: &Lattice) -> Result<Array2<f64>, StaticError> {
let src = Array2::from_shape_fn((self.ncol, self.nrow), |(c, r)| {
self.values[r * self.ncol + c]
});
let src_georef = match self.georef {
Some(g) => Lattice::regular(
g.origin_x, g.origin_y, g.node_dx, g.node_dy, self.ncol, self.nrow,
),
None => {
let o = target.node_xy(0, 0);
let far = target.node_xy(target.ncol - 1, target.nrow - 1);
let dx = span_inc(o.0, far.0, self.ncol);
let dy = span_inc(o.1, far.1, self.nrow);
Lattice::regular(o.0, o.1, dx, dy, self.ncol, self.nrow)
}
};
resample(&src, &src_georef, target, ResampleMethod::Bilinear)
.map_err(|e| StaticError::Grid(format!("trend resample failed: {e}")))
}
fn normalize(raw: Vec<f64>) -> Vec<f64> {
let defined: Vec<f64> = raw.iter().copied().filter(|v| v.is_finite()).collect();
let mean = if defined.is_empty() {
1.0
} else {
defined.iter().sum::<f64>() / defined.len() as f64
};
raw.into_iter()
.map(|v| {
if v.is_finite() && mean > 0.0 {
v / mean
} else {
1.0
}
})
.collect()
}
pub(crate) fn column_multipliers_on(&self, lattice: &Lattice) -> Result<Vec<f64>, StaticError> {
let field = self.resample_to(lattice)?;
let (ni, nj) = (lattice.ncol, lattice.nrow);
let mut raw = vec![f64::NAN; ni * nj];
for j in 0..nj {
for i in 0..ni {
raw[j * ni + i] = field[[i, j]];
}
}
Ok(Self::normalize(raw))
}
}
fn span_inc(start: f64, end: f64, n: usize) -> f64 {
if n < 2 {
return 1.0;
}
let inc = (end - start) / (n - 1) as f64;
if inc.is_finite() && inc > 0.0 {
inc
} else {
1.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_bad_shape_and_negatives() {
assert!(TrendSurface::new(0, 3, vec![]).is_err());
assert!(TrendSurface::new(2, 2, vec![1.0; 3]).is_err()); assert!(TrendSurface::new(2, 1, vec![1.0, -0.5]).is_err()); assert!(TrendSurface::new(2, 1, vec![1.0, f64::NAN]).is_ok()); }
#[test]
fn multipliers_are_mean_normalized() {
let t = TrendSurface::new(2, 1, vec![1.0, 3.0]).unwrap();
let m = t
.column_multipliers_on(&Lattice::regular(0.0, 0.0, 1.0, 1.0, 2, 1))
.unwrap();
assert!((m[0] - 0.5).abs() < 1e-12, "{m:?}");
assert!((m[1] - 1.5).abs() < 1e-12, "{m:?}");
assert!(((m[0] + m[1]) / 2.0 - 1.0).abs() < 1e-12);
}
#[test]
fn georef_resamples_by_world_coordinate() {
let t = TrendSurface::new(3, 1, vec![1.0, 2.0, 3.0])
.unwrap()
.with_georef(0.0, 0.0, 100.0, 100.0);
assert!(t.is_georeferenced());
let m = t
.column_multipliers_on(&Lattice::regular(0.0, 0.0, 100.0, 100.0, 3, 1))
.unwrap();
assert!((m[0] - 0.5).abs() < 1e-12, "{m:?}");
assert!((m[1] - 1.0).abs() < 1e-12, "{m:?}");
assert!((m[2] - 1.5).abs() < 1e-12, "{m:?}");
}
#[test]
fn georef_out_of_extent_falls_back_to_unity() {
let t = TrendSurface::new(2, 1, vec![1.0, 3.0])
.unwrap()
.with_georef(0.0, 0.0, 100.0, 100.0);
let m = t
.column_multipliers_on(&Lattice::regular(0.0, 0.0, 100.0, 100.0, 3, 1))
.unwrap();
assert!((m[0] - 0.5).abs() < 1e-12, "in-extent {m:?}");
assert!((m[1] - 1.5).abs() < 1e-12, "in-extent {m:?}");
assert!((m[2] - 1.0).abs() < 1e-12, "out-of-extent -> unity {m:?}");
}
#[test]
fn georef_off_by_default_and_ignores_bad_spacing() {
let t = TrendSurface::new(2, 1, vec![1.0, 2.0]).unwrap();
assert!(!t.is_georeferenced());
assert!(!t
.clone()
.with_georef(0.0, 0.0, 0.0, 100.0)
.is_georeferenced());
}
#[test]
fn undefined_nodes_fall_back_to_unity() {
let t = TrendSurface::new(2, 1, vec![2.0, f64::NAN]).unwrap();
let m = t
.column_multipliers_on(&Lattice::regular(0.0, 0.0, 1.0, 1.0, 2, 1))
.unwrap();
assert!((m[0] - 1.0).abs() < 1e-12, "{m:?}");
assert!((m[1] - 1.0).abs() < 1e-12, "{m:?}");
}
}