use crate::foundation::{AlgoError, Result};
use crate::sampling::seeded_rng;
use crate::synth::correlated::{ar1_phi, correlated_gaussian};
use crate::synth::transform::LogitNormal;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ZoneSpec {
pub thickness_m: f64,
pub mean: f64,
pub std: f64,
pub corr_length_m: f64,
}
impl ZoneSpec {
pub fn new(thickness_m: f64, mean: f64, std: f64, corr_length_m: f64) -> Result<ZoneSpec> {
if !(thickness_m.is_finite() && thickness_m > 0.0) {
return Err(AlgoError::InvalidArgument(
"ZoneSpec: thickness_m must be finite and > 0".to_string(),
));
}
if !(corr_length_m.is_finite() && corr_length_m > 0.0) {
return Err(AlgoError::InvalidArgument(
"ZoneSpec: corr_length_m must be finite and > 0".to_string(),
));
}
LogitNormal::match_moments(mean, std)?;
Ok(ZoneSpec {
thickness_m,
mean,
std,
corr_length_m,
})
}
}
fn zone_samples(thickness_m: f64, depth_step: f64) -> usize {
((thickness_m / depth_step).round() as usize).max(1)
}
pub fn zone_sample_counts(zones: &[ZoneSpec], depth_step: f64) -> Vec<usize> {
zones
.iter()
.map(|z| zone_samples(z.thickness_m, depth_step))
.collect()
}
pub fn synth_log_series(
zones: &[ZoneSpec],
depth_step: f64,
transition_beds: usize,
seed: u64,
) -> Result<Vec<f64>> {
if zones.is_empty() {
return Err(AlgoError::EmptyInput("synth_log_series: empty zone stack"));
}
if !(depth_step.is_finite() && depth_step > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_log_series: depth_step must be finite and > 0".to_string(),
));
}
let counts = zone_sample_counts(zones, depth_step);
let n: usize = counts.iter().sum();
let mut starts = Vec::with_capacity(zones.len());
let mut zone_of = Vec::with_capacity(n);
let mut acc = 0usize;
for (zi, &c) in counts.iter().enumerate() {
starts.push(acc);
for _ in 0..c {
zone_of.push(zi);
}
acc += c;
}
let mut rng = seeded_rng(seed);
let driver = correlated_gaussian(
n,
|k| ar1_phi(depth_step, zones[zone_of[k]].corr_length_m),
&mut rng,
);
let transforms: Vec<LogitNormal> = zones
.iter()
.map(|z| LogitNormal::match_moments(z.mean, z.std).expect("validated at ZoneSpec::new"))
.collect();
let mut out: Vec<f64> = (0..n)
.map(|k| transforms[zone_of[k]].apply(driver[k]))
.collect();
if transition_beds > 0 {
let h = transition_beds;
for b in 1..zones.len() {
let bidx = starts[b];
let lo = bidx.saturating_sub(h);
let hi = (bidx + h).min(n);
let span = (hi - lo) as f64;
if span <= 0.0 {
continue;
}
for k in lo..hi {
let t = (k - lo) as f64 / span; let blended = transforms[b - 1].lerp(&transforms[b], t);
out[k] = blended.apply(driver[k]);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::{mean, std_dev};
use crate::synth::correlated::lag1_autocorr;
fn zones() -> Vec<ZoneSpec> {
vec![
ZoneSpec::new(20.0, 0.10, 0.03, 4.0).unwrap(),
ZoneSpec::new(30.0, 0.24, 0.04, 6.0).unwrap(),
ZoneSpec::new(15.0, 0.08, 0.02, 3.0).unwrap(),
ZoneSpec::new(25.0, 0.18, 0.05, 5.0).unwrap(),
]
}
#[test]
fn empty_and_bad_args_error() {
assert!(synth_log_series(&[], 0.5, 0, 1).is_err());
assert!(synth_log_series(&zones(), 0.0, 0, 1).is_err());
assert!(synth_log_series(&zones(), -1.0, 0, 1).is_err());
}
#[test]
fn length_matches_layout() {
let z = zones();
let s = synth_log_series(&z, 0.5, 0, 1).unwrap();
let expect: usize = zone_sample_counts(&z, 0.5).iter().sum();
assert_eq!(s.len(), expect);
}
#[test]
fn bit_reproducible() {
let z = zones();
let a = synth_log_series(&z, 0.25, 2, 2026).unwrap();
let b = synth_log_series(&z, 0.25, 2, 2026).unwrap();
assert_eq!(a, b);
}
#[test]
fn bounds_never_violated() {
for seed in 0..5u64 {
let s = synth_log_series(&zones(), 0.25, 1, seed).unwrap();
assert!(s.iter().all(|&v| v > 0.0 && v < 1.0), "out of (0,1)");
}
}
#[test]
fn each_zone_hits_target_moments_across_seeds() {
let z = zones();
let depth_step = 0.25;
let counts = zone_sample_counts(&z, depth_step);
let seeds = [1u64, 2, 3, 4, 5, 6, 7, 8];
for (zi, spec) in z.iter().enumerate() {
let start: usize = counts[..zi].iter().sum();
let end = start + counts[zi];
let mut mbar = 0.0;
let mut sbar = 0.0;
for &seed in &seeds {
let s = synth_log_series(&z, depth_step, 0, seed).unwrap();
let seg = &s[start..end];
mbar += mean(seg).unwrap();
sbar += std_dev(seg).unwrap();
}
mbar /= seeds.len() as f64;
sbar /= seeds.len() as f64;
assert!(
(mbar - spec.mean).abs() < 0.02,
"zone {zi} mean {mbar} vs {}",
spec.mean
);
assert!(
(sbar - spec.std).abs() < 0.02,
"zone {zi} std {sbar} vs {}",
spec.std
);
}
}
#[test]
fn autocorrelation_length_recovered() {
let depth_step = 0.5;
let corr = 10.0;
let z = vec![ZoneSpec::new(400.0, 0.25, 0.05, corr).unwrap()];
let phi = ar1_phi(depth_step, corr);
let mut rbar = 0.0;
let seeds = [10u64, 20, 30, 40];
for &seed in &seeds {
let s = synth_log_series(&z, depth_step, 0, seed).unwrap();
rbar += lag1_autocorr(&s);
}
rbar /= seeds.len() as f64;
let recovered_len = -depth_step / rbar.ln();
assert!(
(recovered_len - corr).abs() / corr < 0.2,
"recovered corr length {recovered_len} vs {corr} (phi {phi}, r {rbar})"
);
}
}