From 080574b112634d2784ed7a88f72e7b73ba019fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Tue, 25 Nov 2025 23:36:47 +0100 Subject: [PATCH] UPDATE: Implement FTEST function --- .../src/expressions/parser/static_analysis.rs | 2 + base/src/functions/mod.rs | 9 +- base/src/functions/statistical/fisher.rs | 119 ++++++++++++++++++ base/src/functions/statistical/t_dist.rs | 2 +- base/src/test/statistical/mod.rs | 1 + base/src/test/statistical/test_fn_f_test.rs | 35 ++++++ xlsx/tests/statistical/F_TEST.xlsx | Bin 0 -> 9436 bytes 7 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 base/src/test/statistical/test_fn_f_test.rs create mode 100644 xlsx/tests/statistical/F_TEST.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 773684e..c99316b 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -929,6 +929,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 0), Function::FInv => args_signature_scalars(arg_count, 3, 0), Function::FInvRT => args_signature_scalars(arg_count, 3, 0), + Function::FTest => vec![Signature::Vector; 2], Function::Fisher => args_signature_scalars(arg_count, 1, 0), Function::FisherInv => args_signature_scalars(arg_count, 1, 0), Function::Gamma => args_signature_scalars(arg_count, 1, 0), @@ -1287,6 +1288,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::FDistRT => StaticResult::Scalar, Function::FInv => StaticResult::Scalar, Function::FInvRT => StaticResult::Scalar, + Function::FTest => StaticResult::Scalar, Function::Fisher => StaticResult::Scalar, Function::FisherInv => StaticResult::Scalar, Function::Gamma => StaticResult::Scalar, diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 56f9ad1..47f82b5 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -212,7 +212,7 @@ pub enum Function { FDistRT, FInv, FInvRT, - // FTest, + FTest, Fisher, FisherInv, // Forecast, @@ -420,7 +420,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -711,6 +711,7 @@ impl Function { Function::FDistRT, Function::FInv, Function::FInvRT, + Function::FTest, Function::Fisher, Function::FisherInv, Function::Gamma, @@ -837,6 +838,7 @@ impl Function { Function::FDistRT => "_xlfn.F.DIST.RT".to_string(), Function::FInv => "_xlfn.F.INV".to_string(), Function::FInvRT => "_xlfn.F.INV.RT".to_string(), + Function::FTest => "_xlfn.F.TEST".to_string(), Function::HypGeomDist => "_xlfn.HYPGEOM.DIST".to_string(), @@ -1186,6 +1188,7 @@ impl Function { "F.DIST.RT" | "_XLFN.F.DIST.RT" => Some(Function::FDistRT), "F.INV" | "_XLFN.F.INV" => Some(Function::FInv), "F.INV.RT" | "_XLFN.F.INV.RT" => Some(Function::FInvRT), + "F.TEST" | "_XLFN.F.TEST" => Some(Function::FTest), "FISHER" => Some(Function::Fisher), "FISHERINV" => Some(Function::FisherInv), "GAMMA" | "_XLFN.GAMMA" => Some(Function::Gamma), @@ -1523,6 +1526,7 @@ impl fmt::Display for Function { Function::FInvRT => write!(f, "F.INV.RT"), Function::Fisher => write!(f, "FISHER"), Function::FisherInv => write!(f, "FISHERINV"), + Function::FTest => write!(f, "F.TEST"), Function::Gamma => write!(f, "GAMMA"), Function::GammaDist => write!(f, "GAMMA.DIST"), Function::GammaInv => write!(f, "GAMMA.INV"), @@ -1875,6 +1879,7 @@ impl Model { Function::FInvRT => self.fn_f_inv_rt(args, cell), Function::Fisher => self.fn_fisher(args, cell), Function::FisherInv => self.fn_fisher_inv(args, cell), + Function::FTest => self.fn_f_test(args, cell), Function::Gamma => self.fn_gamma(args, cell), Function::GammaDist => self.fn_gamma_dist(args, cell), Function::GammaInv => self.fn_gamma_inv(args, cell), diff --git a/base/src/functions/statistical/fisher.rs b/base/src/functions/statistical/fisher.rs index 72fa41b..cd7e698 100644 --- a/base/src/functions/statistical/fisher.rs +++ b/base/src/functions/statistical/fisher.rs @@ -1,6 +1,7 @@ use statrs::distribution::{Continuous, ContinuousCDF, FisherSnedecor}; use crate::expressions::types::CellReferenceIndex; +use crate::functions::statistical::t_dist::sample_var; use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; @@ -296,4 +297,122 @@ impl Model { CalcResult::Number(x) } + + // F.TEST(array1, array2) + pub(crate) fn fn_f_test(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + + let values1_opts = match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Range { left, right } => match self.values_from_range(left, right) { + Ok(v) => v, + Err(error) => return error, + }, + CalcResult::Array(a) => match self.values_from_array(a) { + Ok(v) => v, + Err(error) => { + return CalcResult::new_error( + Error::VALUE, + cell, + format!("Error in first array: {:?}", error), + ); + } + }, + _ => { + return CalcResult::new_error( + Error::VALUE, + cell, + "First argument must be a range or array".to_string(), + ); + } + }; + + // Get second sample as Vec> + let values2_opts = match self.evaluate_node_in_context(&args[1], cell) { + CalcResult::Range { left, right } => match self.values_from_range(left, right) { + Ok(v) => v, + Err(error) => return error, + }, + CalcResult::Array(a) => match self.values_from_array(a) { + Ok(v) => v, + Err(error) => { + return CalcResult::new_error( + Error::VALUE, + cell, + format!("Error in second array: {:?}", error), + ); + } + }, + _ => { + return CalcResult::new_error( + Error::VALUE, + cell, + "Second argument must be a range or array".to_string(), + ); + } + }; + + let values1: Vec = values1_opts.into_iter().flatten().collect(); + let values2: Vec = values2_opts.into_iter().flatten().collect(); + + let n1 = values1.len(); + let n2 = values2.len(); + + // If fewer than 2 numeric values in either sample -> #DIV/0! + if n1 < 2 || n2 < 2 { + return CalcResult::new_error( + Error::DIV, + cell, + "F.TEST requires at least two numeric values in each sample".to_string(), + ); + } + + let v1 = sample_var(&values1); + let v2 = sample_var(&values2); + + if v1 <= 0.0 || v2 <= 0.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "Variance of one sample is zero in F.TEST".to_string(), + ); + } + + // F ratio: larger variance / smaller variance + let mut f = v1 / v2; + let mut df1 = (n1 - 1) as f64; + let mut df2 = (n2 - 1) as f64; + + if f < 1.0 { + f = 1.0 / f; + std::mem::swap(&mut df1, &mut df2); + } + + let dist = match FisherSnedecor::new(df1, df2) { + Ok(d) => d, + Err(_) => { + return CalcResult::new_error( + Error::NUM, + cell, + "Invalid parameters for F distribution in F.TEST".to_string(), + ); + } + }; + + // One-tailed right-tail probability + let tail = 1.0 - dist.cdf(f); + // F.TEST is two-tailed: p = 2 * tail (with F >= 1) + let mut p = 2.0 * tail; + + // Clamp tiny FP noise + if p < 0.0 && p > -1e-15 { + p = 0.0; + } + if p > 1.0 && p < 1.0 + 1e-15 { + p = 1.0; + } + + CalcResult::Number(p) + } } diff --git a/base/src/functions/statistical/t_dist.rs b/base/src/functions/statistical/t_dist.rs index f545be7..6e64100 100644 --- a/base/src/functions/statistical/t_dist.rs +++ b/base/src/functions/statistical/t_dist.rs @@ -17,7 +17,7 @@ fn mean(xs: &[f64]) -> f64 { s / (n as f64) } -fn sample_var(xs: &[f64]) -> f64 { +pub(crate) fn sample_var(xs: &[f64]) -> f64 { let n = xs.len(); if n < 2 { return 0.0; diff --git a/base/src/test/statistical/mod.rs b/base/src/test/statistical/mod.rs index a96151a..8f7c66a 100644 --- a/base/src/test/statistical/mod.rs +++ b/base/src/test/statistical/mod.rs @@ -7,6 +7,7 @@ mod test_fn_covariance; mod test_fn_devsq; mod test_fn_expon_dist; mod test_fn_f; +mod test_fn_f_test; mod test_fn_fisher; mod test_fn_hyp_geom_dist; mod test_fn_log_norm; diff --git a/base/src/test/statistical/test_fn_f_test.rs b/base/src/test/statistical/test_fn_f_test.rs new file mode 100644 index 0000000..6bda7e9 --- /dev/null +++ b/base/src/test/statistical/test_fn_f_test.rs @@ -0,0 +1,35 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_f_test_sanity() { + let mut model = new_empty_model(); + + // Valid call + model._set("A1", "=F.TEST(A2:A7, B2:B7)"); + model._set("A2", "9"); + model._set("A3", "12"); + model._set("A4", "14"); + model._set("A5", "16"); + model._set("A6", "18"); + model._set("A7", "20"); + model._set("B2", "11"); + model._set("B3", "10"); + model._set("B4", "15"); + model._set("B5", "17"); + model._set("B6", "19"); + model._set("B7", "21"); + + // Too few args + model._set("A8", "=F.TEST(A2:A7)"); + + // Too many args + model._set("A9", "=F.TEST(A2:A7, B2:B7, C2:C7)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.859284302"); + assert_eq!(model._get_text("A8"), *"#ERROR!"); + assert_eq!(model._get_text("A9"), *"#ERROR!"); +} diff --git a/xlsx/tests/statistical/F_TEST.xlsx b/xlsx/tests/statistical/F_TEST.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..83d4e4070f61f84039548b9171628a6e32ba5297 GIT binary patch literal 9436 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 literal 0 HcmV?d00001