diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..8e69679b0 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -785,6 +785,9 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], + Function::Median => vec![Signature::Vector; arg_count], + Function::StdevS => vec![Signature::Vector; arg_count], + Function::StdevP => vec![Signature::Vector; arg_count], } } @@ -990,5 +993,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Median => not_implemented(args), + Function::StdevS => not_implemented(args), + Function::StdevP => not_implemented(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..1876a36f9 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,6 +145,9 @@ pub enum Function { Maxifs, Minifs, Geomean, + Median, + StdevS, + StdevP, // Date and time Date, @@ -253,7 +256,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -357,6 +360,9 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::Median, + Function::StdevS, + Function::StdevP, Function::Year, Function::Day, Function::Month, @@ -625,6 +631,9 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "MEDIAN" => Some(Function::Median), + "STDEV.S" => Some(Function::StdevS), + "STDEV.P" => Some(Function::StdevP), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), @@ -836,6 +845,9 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::Median => write!(f, "MEDIAN"), + Function::StdevS => write!(f, "STDEV.S"), + Function::StdevP => write!(f, "STDEV.P"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), @@ -1076,6 +1088,9 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::Median => self.fn_median(args, cell), + Function::StdevS => self.fn_stdev_s(args, cell), + Function::StdevP => self.fn_stdev_p(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..154e21e6a 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -7,7 +7,8 @@ use crate::{ model::Model, }; -use super::util::build_criteria; +use super::util::{build_criteria, collect_numeric_values}; +use std::cmp::Ordering; impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { @@ -654,80 +655,87 @@ impl Model { if args.is_empty() { return CalcResult::new_args_number_error(cell); } - let mut count = 0.0; - let mut product = 1.0; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - count += 1.0; - product *= value; - } - CalcResult::Boolean(b) => { - if let Node::ReferenceKind { .. } = arg { - } else { - product *= if b { 1.0 } else { 0.0 }; - count += 1.0; - } - } - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - ); - } - for row in left.row..(right.row + 1) { - for column in left.column..(right.column + 1) { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - count += 1.0; - product *= value; - } - error @ CalcResult::Error { .. } => return error, - CalcResult::Range { .. } => { - return CalcResult::new_error( - Error::ERROR, - cell, - "Unexpected Range".to_string(), - ); - } - _ => {} - } - } - } - } - error @ CalcResult::Error { .. } => return error, - CalcResult::String(s) => { - if let Node::ReferenceKind { .. } = arg { - // Do nothing - } else if let Ok(t) = s.parse::() { - product *= t; - count += 1.0; - } else { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Argument cannot be cast into number".to_string(), - }; - } - } - _ => { - // Ignore everything else - } - }; - } - if count == 0.0 { + let values = match collect_numeric_values(self, args, cell) { + Ok(v) => v, + Err(err) => return err, + }; + + if values.is_empty() { return CalcResult::Error { error: Error::DIV, origin: cell, message: "Division by Zero".to_string(), }; } + + let product: f64 = values.iter().product(); + let count = values.len() as f64; CalcResult::Number(product.powf(1.0 / count)) } + + pub(crate) fn fn_median(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut values = match collect_numeric_values(self, args, cell) { + Ok(v) => v, + Err(err) => return err, + }; + if values.is_empty() { + return CalcResult::Number(0.0); + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + let len = values.len(); + if len % 2 == 1 { + CalcResult::Number(values[len / 2]) + } else { + CalcResult::Number((values[len / 2 - 1] + values[len / 2]) / 2.0) + } + } + + pub(crate) fn fn_stdev_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell) { + Ok(v) => v, + Err(err) => return err, + }; + let n = values.len(); + if n < 2 { + return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); + } + let sum: f64 = values.iter().sum(); + let mean = sum / n as f64; + let mut variance = 0.0; + for v in &values { + variance += (v - mean).powi(2); + } + variance /= n as f64 - 1.0; + CalcResult::Number(variance.sqrt()) + } + + pub(crate) fn fn_stdev_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let values = match collect_numeric_values(self, args, cell) { + Ok(v) => v, + Err(err) => return err, + }; + let n = values.len(); + if n == 0 { + return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); + } + let sum: f64 = values.iter().sum(); + let mean = sum / n as f64; + let mut variance = 0.0; + for v in &values { + variance += (v - mean).powi(2); + } + variance /= n as f64; + CalcResult::Number(variance.sqrt()) + } + + // collect_numeric_values moved to functions::util } diff --git a/base/src/functions/util.rs b/base/src/functions/util.rs index dea96e843..6b978d6e2 100644 --- a/base/src/functions/util.rs +++ b/base/src/functions/util.rs @@ -1,7 +1,15 @@ #[cfg(feature = "use_regex_lite")] use regex_lite as regex; -use crate::{calc_result::CalcResult, expressions::token::is_english_error_string}; +use crate::{ + calc_result::CalcResult, + expressions::{ + parser::Node, + token::{is_english_error_string, Error}, + types::CellReferenceIndex, + }, + model::Model, +}; /// This test for exact match (modulo case). /// * strings are not cast into bools or numbers @@ -398,3 +406,79 @@ pub(crate) fn build_criteria<'a>(value: &'a CalcResult) -> Box Box::new(result_is_equal_to_empty), } } + +/// Collects all numeric values from a function’s argument list. +/// +/// Traverses each Node, evaluates it in context, and returns the numeric +/// scalars as `Ok(Vec)`. Propagates the first error encountered. +/// +/// Behaviour rules (Excel-compatible): +/// • Booleans in literals become 1/0; booleans coming from cell references are ignored. +/// • Strings that can be parsed as numbers are accepted when literal (not via reference). +/// • Non-numeric values, empty cells, and text are skipped. +/// • Encountered `#ERROR!` values are propagated immediately. +/// • Ranges are flattened cell-by-cell; cross-sheet ranges trigger `#VALUE!`. +/// +/// Requires `&mut Model` because range evaluation queries live cell state. +pub(crate) fn collect_numeric_values( + model: &mut Model, + args: &[Node], + cell: CellReferenceIndex, +) -> Result, CalcResult> { + let mut values = Vec::new(); + for arg in args { + match model.evaluate_node_in_context(arg, cell) { + CalcResult::Number(v) => values.push(v), + CalcResult::Boolean(b) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + values.push(if b { 1.0 } else { 0.0 }); + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match model.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => values.push(v), + error @ CalcResult::Error { .. } => return Err(error), + CalcResult::Range { .. } => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + "Unexpected Range".to_string(), + )); + } + _ => {} + } + } + } + } + error @ CalcResult::Error { .. } => return Err(error), + CalcResult::String(s) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument cannot be cast into number".to_string(), + }); + } + } + } + _ => {} + } + } + Ok(values) +} diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..f74a441d4 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -64,8 +64,10 @@ mod test_issue_155; mod test_ln; mod test_log; mod test_log10; +mod test_median; mod test_percentage; mod test_set_functions_error_handling; +mod test_stdev; mod test_today; mod test_types; mod user_model; diff --git a/base/src/test/test_median.rs b/base/src/test/test_median.rs new file mode 100644 index 000000000..8391c0263 --- /dev/null +++ b/base/src/test/test_median.rs @@ -0,0 +1,27 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_median_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=MEDIAN()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_median_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 empty + model._set("B6", "true"); + model._set("A1", "=MEDIAN(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); +} diff --git a/base/src/test/test_stdev.rs b/base/src/test/test_stdev.rs new file mode 100644 index 000000000..8619cc69e --- /dev/null +++ b/base/src/test/test_stdev.rs @@ -0,0 +1,31 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_stdev_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=STDEV.S()"); + model._set("A2", "=STDEV.P()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn test_fn_stdev_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 empty + model._set("B6", "true"); + model._set("A1", "=STDEV.S(B1:B6)"); + model._set("A2", "=STDEV.P(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"0.816496581"); +}