use ndarray::{Array2, ArrayView1, ArrayView2, Zip};
use crate::ClimateError;
const HASTINGS_C0: f64 = 2.515517;
const HASTINGS_C1: f64 = 0.802853;
const HASTINGS_C2: f64 = 0.010328;
const HASTINGS_D1: f64 = 1.432788;
const HASTINGS_D2: f64 = 0.189269;
const HASTINGS_D3: f64 = 0.001308;
pub fn tukey_probabilities(
climatology: ArrayView2<'_, f64>,
values: ArrayView2<'_, f64>,
pads: ArrayView1<'_, f64>,
) -> Result<Array2<f64>, ClimateError> {
let columns = values.ncols();
if climatology.ncols() != columns {
return Err(ClimateError::ShapeMismatch {
argument: "climatology",
expected: columns,
actual: climatology.ncols(),
});
}
if pads.len() != columns {
return Err(ClimateError::ShapeMismatch {
argument: "pads",
expected: columns,
actual: pads.len(),
});
}
let mut probabilities = Array2::<f64>::zeros(values.raw_dim());
Zip::from(probabilities.columns_mut())
.and(climatology.columns())
.and(values.columns())
.and(pads)
.for_each(|mut out, climatology, values, &pad| {
let valid = climatology.iter().filter(|value| !value.is_nan()).count() as f64;
for (probability, &value) in out.iter_mut().zip(values.iter()) {
*probability = if value.is_nan() || valid < 2.0 {
f64::NAN
} else {
let below = climatology
.iter()
.filter(|climatology| **climatology < value)
.count() as f64;
(pad + below + 0.66) / (valid + pad + 0.33)
};
}
});
Ok(probabilities)
}
pub fn hastings_inverse_normal(probability: f64) -> f64 {
let probability = probability.clamp(1e-10, 1.0 - 1e-10);
let sign = if probability <= 0.5 { -1.0 } else { 1.0 };
let lower_tail = if probability <= 0.5 {
probability
} else {
1.0 - probability
};
let t = (-2.0 * lower_tail.ln()).sqrt();
let numerator = HASTINGS_C0 + t * (HASTINGS_C1 + t * HASTINGS_C2);
let denominator = 1.0 + t * (HASTINGS_D1 + t * (HASTINGS_D2 + t * HASTINGS_D3));
sign * (t - numerator / denominator)
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn probabilities_rank_strictly_and_place_the_pads() {
let probabilities = tukey_probabilities(
array![[1.0], [2.0]].view(),
array![[1.0], [2.0], [3.0]].view(),
array![0.0].view(),
)
.unwrap();
assert_eq!(probabilities[[0, 0]], 0.283_261_802_575_107_3);
assert_eq!(probabilities[[1, 0]], 0.712_446_351_931_330_5);
assert_eq!(probabilities[[2, 0]], 1.141_630_901_287_553_6);
let padded = tukey_probabilities(
array![[1.0], [2.0]].view(),
array![[1.0], [3.0]].view(),
array![1.0].view(),
)
.unwrap();
assert_eq!(padded[[0, 0]], 0.498_498_498_498_498_53);
assert_eq!(padded[[1, 0]], 1.099_099_099_099_099_2);
}
#[test]
fn ties_rank_by_the_strict_comparison() {
let probabilities = tukey_probabilities(
array![[1.0, 2.0], [2.0, 2.0], [3.0, 2.0]].view(),
array![[2.0, 2.0]].view(),
array![0.0, 0.0].view(),
)
.unwrap();
assert_eq!(probabilities[[0, 0]], 0.498_498_498_498_498_53);
assert_eq!(probabilities[[0, 1]], 0.198_198_198_198_198_2);
}
#[test]
fn missing_values_and_short_climatologies_are_nan() {
let probabilities = tukey_probabilities(
array![[1.0, f64::NAN], [2.0, 3.0], [3.0, f64::NAN]].view(),
array![[2.0, 3.0], [f64::NAN, 3.0]].view(),
array![0.0, 0.0].view(),
)
.unwrap();
assert_eq!(probabilities[[0, 0]], 0.498_498_498_498_498_53);
assert!(probabilities[[1, 0]].is_nan());
assert!(probabilities[[0, 1]].is_nan());
assert!(probabilities[[1, 1]].is_nan());
}
#[test]
fn probabilities_reject_mismatched_shapes() {
let error = tukey_probabilities(
array![[1.0, 2.0]].view(),
array![[1.0]].view(),
array![0.0].view(),
)
.unwrap_err();
assert_eq!(
error,
ClimateError::ShapeMismatch {
argument: "climatology",
expected: 1,
actual: 2
}
);
let error = tukey_probabilities(
array![[1.0]].view(),
array![[1.0]].view(),
array![0.0, 0.0].view(),
)
.unwrap_err();
assert_eq!(
error,
ClimateError::ShapeMismatch {
argument: "pads",
expected: 1,
actual: 2
}
);
}
#[test]
fn inverse_normal_matches_the_python_approximation() {
let cases = [
(0.5, 1.010_066_754_680_849_5e-7),
(0.66 / 3.33, -0.847_915_598_085_17),
(1.66 / 3.33, -0.003_752_519_260_830_755_6),
(1e-10, -6.360_938_869_405_1),
(1.0 - 1e-10, 6.360_938_856_699_430_5),
(0.025, -1.960_394_916_925_34),
(0.975, 1.960_394_916_925_339_6),
];
for (probability, expected) in cases {
let actual = hastings_inverse_normal(probability);
assert!(
actual.is_finite() && (actual - expected).abs() <= 1e-10,
"hastings_inverse_normal({probability}): actual={actual}, expected={expected}"
);
}
assert_eq!(hastings_inverse_normal(0.0), hastings_inverse_normal(1e-10));
assert_eq!(
hastings_inverse_normal(1.0),
hastings_inverse_normal(1.0 - 1e-10)
);
assert!(hastings_inverse_normal(f64::NAN).is_nan());
}
}