use crate::foundation::{AlgoError, Result};
use crate::sampling::seeded_rng;
use crate::synth::correlated::{ar1_phi, correlated_gaussian};
use crate::synth::transform::LogitNormal;
use statrs::distribution::{ContinuousCDF, Normal as StatrsNormal};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Facies {
Sand,
Shale,
}
impl Facies {
pub fn is_sand(self) -> bool {
matches!(self, Facies::Sand)
}
pub fn code(self) -> u8 {
match self {
Facies::Sand => 1,
Facies::Shale => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MomentSpec {
pub mean: f64,
pub std: f64,
}
impl MomentSpec {
pub fn new(mean: f64, std: f64) -> Result<MomentSpec> {
LogitNormal::match_moments(mean, std)?;
Ok(MomentSpec { mean, std })
}
}
pub(crate) fn mix_seed(seed: u64) -> u64 {
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
pub fn synth_facies_series(
n: usize,
depth_step: f64,
ntg_target: f64,
bed_scale_m: f64,
seed: u64,
) -> Result<Vec<Facies>> {
if n == 0 {
return Err(AlgoError::EmptyInput("synth_facies_series: n = 0"));
}
if !(depth_step.is_finite() && depth_step > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_facies_series: depth_step must be finite and > 0".to_string(),
));
}
if !(ntg_target.is_finite()) || ntg_target <= 0.0 || ntg_target >= 1.0 {
return Err(AlgoError::InvalidArgument(
"synth_facies_series: need 0 < ntg_target < 1".to_string(),
));
}
if !(bed_scale_m.is_finite() && bed_scale_m > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_facies_series: bed_scale_m must be finite and > 0".to_string(),
));
}
let snorm = StatrsNormal::new(0.0, 1.0).expect("standard normal");
let t = snorm.inverse_cdf(1.0 - ntg_target);
let phi = ar1_phi(depth_step, bed_scale_m);
let mut rng = seeded_rng(seed);
let z = correlated_gaussian(n, |_| phi, &mut rng);
Ok(z.into_iter()
.map(|v| if v > t { Facies::Sand } else { Facies::Shale })
.collect())
}
pub fn synth_por_with_facies(
facies: &[Facies],
depth_step: f64,
sand: MomentSpec,
shale: MomentSpec,
corr_length_m: f64,
seed: u64,
) -> Result<Vec<f64>> {
if facies.is_empty() {
return Err(AlgoError::EmptyInput("synth_por_with_facies: empty facies"));
}
if !(depth_step.is_finite() && depth_step > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_por_with_facies: depth_step must be finite and > 0".to_string(),
));
}
if !(corr_length_m.is_finite() && corr_length_m > 0.0) {
return Err(AlgoError::InvalidArgument(
"synth_por_with_facies: corr_length_m must be finite and > 0".to_string(),
));
}
let t_sand = LogitNormal::match_moments(sand.mean, sand.std)?;
let t_shale = LogitNormal::match_moments(shale.mean, shale.std)?;
let phi = ar1_phi(depth_step, corr_length_m);
let mut rng = seeded_rng(mix_seed(seed));
let driver = correlated_gaussian(facies.len(), |_| phi, &mut rng);
Ok(facies
.iter()
.zip(driver.iter())
.map(|(f, &z)| {
if f.is_sand() {
t_sand.apply(z)
} else {
t_shale.apply(z)
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::mean;
#[test]
fn bad_args_error() {
assert!(synth_facies_series(0, 0.5, 0.5, 3.0, 1).is_err());
assert!(synth_facies_series(10, 0.0, 0.5, 3.0, 1).is_err());
assert!(synth_facies_series(10, 0.5, 0.0, 3.0, 1).is_err());
assert!(synth_facies_series(10, 0.5, 1.0, 3.0, 1).is_err());
assert!(synth_facies_series(10, 0.5, 0.5, 0.0, 1).is_err());
}
#[test]
fn proportion_matches_ntg_across_seeds() {
let depth_step = 0.25;
for &ntg in &[0.3_f64, 0.5, 0.7] {
let mut pbar = 0.0;
let seeds = [1u64, 2, 3, 4, 5, 6];
for &seed in &seeds {
let f = synth_facies_series(4000, depth_step, ntg, 2.0, seed).unwrap();
let sand = f.iter().filter(|x| x.is_sand()).count() as f64 / f.len() as f64;
pbar += sand;
}
pbar /= seeds.len() as f64;
assert!(
(pbar - ntg).abs() < 0.03,
"sand fraction {pbar} vs ntg {ntg}"
);
}
}
#[test]
fn bit_reproducible() {
let a = synth_facies_series(500, 0.5, 0.6, 3.0, 99).unwrap();
let b = synth_facies_series(500, 0.5, 0.6, 3.0, 99).unwrap();
assert_eq!(a, b);
}
#[test]
fn larger_bed_scale_makes_thicker_beds() {
let count_flips = |bed: f64| {
let f = synth_facies_series(4000, 0.25, 0.5, bed, 7).unwrap();
f.windows(2).filter(|w| w[0] != w[1]).count()
};
assert!(
count_flips(6.0) < count_flips(1.0),
"thicker beds should flip less"
);
}
#[test]
fn porosity_contrast_and_bounds() {
let depth_step = 0.25;
let facies = synth_facies_series(4000, depth_step, 0.5, 2.0, 5).unwrap();
let sand = MomentSpec::new(0.26, 0.03).unwrap();
let shale = MomentSpec::new(0.08, 0.02).unwrap();
let por = synth_por_with_facies(&facies, depth_step, sand, shale, 1.5, 5).unwrap();
assert_eq!(por.len(), facies.len());
assert!(por.iter().all(|&v| v > 0.0 && v < 1.0), "bounds violated");
let sand_por: Vec<f64> = por
.iter()
.zip(&facies)
.filter(|(_, f)| f.is_sand())
.map(|(&p, _)| p)
.collect();
let shale_por: Vec<f64> = por
.iter()
.zip(&facies)
.filter(|(_, f)| !f.is_sand())
.map(|(&p, _)| p)
.collect();
let ms = mean(&sand_por).unwrap();
let mh = mean(&shale_por).unwrap();
assert!(
ms > mh + 0.1,
"sand por {ms} not clearly above shale por {mh}"
);
assert!((ms - 0.26).abs() < 0.02, "sand por mean {ms}");
assert!((mh - 0.08).abs() < 0.02, "shale por mean {mh}");
}
}