From 8597d14a4eedc26d9f5d3c25c40f653ec8d379e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Wed, 26 Nov 2025 21:45:38 +0100 Subject: [PATCH] UPDATE: Implements CORREL, SLOPE, INTERCEPT, RSQ and STEYX These are all functions that follow a very simmilar path code --- .../src/expressions/parser/static_analysis.rs | 10 + base/src/functions/mod.rs | 28 ++- base/src/functions/statistical/correl.rs | 227 ++++++++++++++++++ base/src/functions/statistical/mod.rs | 1 + base/src/functions/statistical/pearson.rs | 47 ++++ .../CORREL_SLOPE_INTERCEPT_RSQ_STEYX.xlsx | Bin 0 -> 9606 bytes 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 base/src/functions/statistical/correl.rs create mode 100644 xlsx/tests/statistical/CORREL_SLOPE_INTERCEPT_RSQ_STEYX.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index eb24277..e3e5ef1 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -990,6 +990,11 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; 2], Function::Sumx2py2 => vec![Signature::Vector; 2], Function::Sumxmy2 => vec![Signature::Vector; 2], + Function::Correl => vec![Signature::Vector; 2], + Function::Rsq => vec![Signature::Vector; 2], + Function::Intercept => vec![Signature::Vector; 2], + Function::Slope => vec![Signature::Vector; 2], + Function::Steyx => vec![Signature::Vector; 2], } } @@ -1324,5 +1329,10 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Sumx2my2 => StaticResult::Scalar, Function::Sumx2py2 => StaticResult::Scalar, Function::Sumxmy2 => StaticResult::Scalar, + Function::Correl => StaticResult::Scalar, + Function::Rsq => StaticResult::Scalar, + Function::Intercept => StaticResult::Scalar, + Function::Slope => StaticResult::Scalar, + Function::Steyx => StaticResult::Scalar, } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index a522330..a66f7d9 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -421,10 +421,16 @@ pub enum Function { Dvar, Dvarp, Dstdevp, + + Correl, + Rsq, + Intercept, + Slope, + Steyx, } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -754,6 +760,11 @@ impl Function { Function::VarA, Function::WeibullDist, Function::ZTest, + Function::Correl, + Function::Rsq, + Function::Intercept, + Function::Slope, + Function::Steyx, ] .into_iter() } @@ -1234,6 +1245,11 @@ impl Function { "SUMX2MY2" => Some(Function::Sumx2my2), "SUMX2PY2" => Some(Function::Sumx2py2), "SUMXMY2" => Some(Function::Sumxmy2), + "CORREL" => Some(Function::Correl), + "RSQ" => Some(Function::Rsq), + "INTERCEPT" => Some(Function::Intercept), + "SLOPE" => Some(Function::Slope), + "STEYX" => Some(Function::Steyx), _ => None, } @@ -1573,6 +1589,11 @@ impl fmt::Display for Function { Function::Sumx2my2 => write!(f, "SUMX2MY2"), Function::Sumx2py2 => write!(f, "SUMX2PY2"), Function::Sumxmy2 => write!(f, "SUMXMY2"), + Function::Correl => write!(f, "CORREL"), + Function::Rsq => write!(f, "RSQ"), + Function::Intercept => write!(f, "INTERCEPT"), + Function::Slope => write!(f, "SLOPE"), + Function::Steyx => write!(f, "STEYX"), } } } @@ -1929,6 +1950,11 @@ impl Model { Function::Sumx2my2 => self.fn_sumx2my2(args, cell), Function::Sumx2py2 => self.fn_sumx2py2(args, cell), Function::Sumxmy2 => self.fn_sumxmy2(args, cell), + Function::Correl => self.fn_correl(args, cell), + Function::Rsq => self.fn_rsq(args, cell), + Function::Intercept => self.fn_intercept(args, cell), + Function::Slope => self.fn_slope(args, cell), + Function::Steyx => self.fn_steyx(args, cell), } } } diff --git a/base/src/functions/statistical/correl.rs b/base/src/functions/statistical/correl.rs new file mode 100644 index 0000000..44aa456 --- /dev/null +++ b/base/src/functions/statistical/correl.rs @@ -0,0 +1,227 @@ +use crate::expressions::types::CellReferenceIndex; +use crate::{ + calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, +}; + +impl Model { + // CORREL(array1, array2) - Returns the correlation coefficient of two data sets + pub(crate) fn fn_correl(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (_, _, values_left, values_right) = match self.fn_get_two_matrices(args, cell) { + Ok(s) => s, + Err(e) => return e, + }; + + let mut n = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_x2 = 0.0; + let mut sum_y2 = 0.0; + let mut sum_xy = 0.0; + + for (x_opt, y_opt) in values_left.into_iter().zip(values_right.into_iter()) { + if let (Some(x), Some(y)) = (x_opt, y_opt) { + n += 1.0; + sum_x += x; + sum_y += y; + sum_x2 += x * x; + sum_y2 += y * y; + sum_xy += x * y; + } + } + + // Need at least 2 valid pairs + if n < 2.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "CORREL requires at least two numeric data points in each range".to_string(), + ); + } + + let num = n * sum_xy - sum_x * sum_y; + let denom_x = n * sum_x2 - sum_x * sum_x; + let denom_y = n * sum_y2 - sum_y * sum_y; + let denom = (denom_x * denom_y).sqrt(); + + if denom == 0.0 || !denom.is_finite() { + return CalcResult::new_error( + Error::DIV, + cell, + "Division by zero in CORREL".to_string(), + ); + } + + let r = num / denom; + CalcResult::Number(r) + } + + // SLOPE(known_y's, known_x's) - Returns the slope of the linear regression line + pub(crate) fn fn_slope(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (_rows, _cols, values_y, values_x) = match self.fn_get_two_matrices(args, cell) { + Ok(s) => s, + Err(e) => return e, + }; + + let mut n = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_x2 = 0.0; + let mut sum_xy = 0.0; + + let len = values_y.len().min(values_x.len()); + for i in 0..len { + if let (Some(y), Some(x)) = (values_y[i], values_x[i]) { + n += 1.0; + sum_x += x; + sum_y += y; + sum_x2 += x * x; + sum_xy += x * y; + } + } + + if n < 2.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "SLOPE requires at least two numeric data points".to_string(), + ); + } + + let denom = n * sum_x2 - sum_x * sum_x; + if denom == 0.0 || !denom.is_finite() { + return CalcResult::new_error( + Error::DIV, + cell, + "Division by zero in SLOPE".to_string(), + ); + } + + let num = n * sum_xy - sum_x * sum_y; + let slope = num / denom; + + CalcResult::Number(slope) + } + + // INTERCEPT(known_y's, known_x's) - Returns the y-intercept of the linear regression line + pub(crate) fn fn_intercept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (_rows, _cols, values_y, values_x) = match self.fn_get_two_matrices(args, cell) { + Ok(s) => s, + Err(e) => return e, + }; + + let mut n = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_x2 = 0.0; + let mut sum_xy = 0.0; + + let len = values_y.len().min(values_x.len()); + for i in 0..len { + if let (Some(y), Some(x)) = (values_y[i], values_x[i]) { + n += 1.0; + sum_x += x; + sum_y += y; + sum_x2 += x * x; + sum_xy += x * y; + } + } + + if n < 2.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "INTERCEPT requires at least two numeric data points".to_string(), + ); + } + + let denom = n * sum_x2 - sum_x * sum_x; + if denom == 0.0 || !denom.is_finite() { + return CalcResult::new_error( + Error::DIV, + cell, + "Division by zero in INTERCEPT".to_string(), + ); + } + + let num = n * sum_xy - sum_x * sum_y; + let slope = num / denom; + let intercept = (sum_y - slope * sum_x) / n; + + CalcResult::Number(intercept) + } + + // STEYX(known_y's, known_x's) - Returns the standard error of the predicted y-values + pub(crate) fn fn_steyx(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (_rows, _cols, values_y, values_x) = match self.fn_get_two_matrices(args, cell) { + Ok(s) => s, + Err(e) => return e, + }; + + let mut n = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_x2 = 0.0; + let mut sum_xy = 0.0; + + // We need the actual pairs again later for residuals + let mut pairs: Vec<(f64, f64)> = Vec::new(); + + let len = values_y.len().min(values_x.len()); + for i in 0..len { + if let (Some(y), Some(x)) = (values_y[i], values_x[i]) { + n += 1.0; + sum_x += x; + sum_y += y; + sum_x2 += x * x; + sum_xy += x * y; + pairs.push((x, y)); + } + } + + // Need at least 3 points for STEYX (n - 2 in denominator) + if n < 3.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "STEYX requires at least three numeric data points".to_string(), + ); + } + + let denom = n * sum_x2 - sum_x * sum_x; + if denom == 0.0 || !denom.is_finite() { + return CalcResult::new_error( + Error::DIV, + cell, + "Division by zero in STEYX".to_string(), + ); + } + + let num = n * sum_xy - sum_x * sum_y; + let slope = num / denom; + let intercept = (sum_y - slope * sum_x) / n; + + // Sum of squared residuals: Σ (y - ŷ)^2, ŷ = intercept + slope * x + let mut sse = 0.0; + for (x, y) in pairs { + let y_hat = intercept + slope * x; + let diff = y - y_hat; + sse += diff * diff; + } + + let dof = n - 2.0; + if dof <= 0.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "STEYX has non-positive degrees of freedom".to_string(), + ); + } + + let sey = (sse / dof).sqrt(); + if !sey.is_finite() { + return CalcResult::new_error(Error::DIV, cell, "Numerical error in STEYX".to_string()); + } + + CalcResult::Number(sey) + } +} diff --git a/base/src/functions/statistical/mod.rs b/base/src/functions/statistical/mod.rs index cb08021..6e31366 100644 --- a/base/src/functions/statistical/mod.rs +++ b/base/src/functions/statistical/mod.rs @@ -1,6 +1,7 @@ mod beta; mod binom; mod chisq; +mod correl; mod count_and_average; mod covariance; mod devsq; diff --git a/base/src/functions/statistical/pearson.rs b/base/src/functions/statistical/pearson.rs index a72da17..fd523e0 100644 --- a/base/src/functions/statistical/pearson.rs +++ b/base/src/functions/statistical/pearson.rs @@ -63,4 +63,51 @@ impl Model { CalcResult::Number(num / denom) } + + // RSQ(array1, array2) = CORREL(array1, array2)^2 + pub(crate) fn fn_rsq(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let (_rows, _cols, values1, values2) = match self.fn_get_two_matrices(args, cell) { + Ok(s) => s, + Err(e) => return e, + }; + + let mut n = 0.0_f64; + let mut sum_x = 0.0_f64; + let mut sum_y = 0.0_f64; + let mut sum_x2 = 0.0_f64; + let mut sum_y2 = 0.0_f64; + let mut sum_xy = 0.0_f64; + + let len = values1.len().min(values2.len()); + for i in 0..len { + if let (Some(x), Some(y)) = (values1[i], values2[i]) { + n += 1.0; + sum_x += x; + sum_y += y; + sum_x2 += x * x; + sum_y2 += y * y; + sum_xy += x * y; + } + } + + if n < 2.0 { + return CalcResult::new_error( + Error::DIV, + cell, + "RSQ requires at least two numeric data points in each range".to_string(), + ); + } + + let num = n * sum_xy - sum_x * sum_y; + let denom_x = n * sum_x2 - sum_x * sum_x; + let denom_y = n * sum_y2 - sum_y * sum_y; + let denom = (denom_x * denom_y).sqrt(); + + if denom == 0.0 || !denom.is_finite() { + return CalcResult::new_error(Error::DIV, cell, "Division by zero in RSQ".to_string()); + } + + let r = num / denom; + CalcResult::Number(r * r) + } } diff --git a/xlsx/tests/statistical/CORREL_SLOPE_INTERCEPT_RSQ_STEYX.xlsx b/xlsx/tests/statistical/CORREL_SLOPE_INTERCEPT_RSQ_STEYX.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7e745bbca4700dca8390566a9e7be920e2824ea0 GIT binary patch literal 9606 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 Q0|W4LJ5vqUEdhZ411a{+r~m)} literal 0 HcmV?d00001