From 525576dc6b1178f9ea426596bbb98c7c8335187c Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Fri, 1 Aug 2025 14:13:28 -0700 Subject: [PATCH 1/2] Add HARMEAN, AVEDEV and DEVSQ functions --- .../src/expressions/parser/static_analysis.rs | 6 + base/src/functions/mod.rs | 17 +- base/src/functions/statistical.rs | 251 ++++++++++++++++++ base/src/test/mod.rs | 3 + base/src/test/test_fn_avedev.rs | 26 ++ base/src/test/test_fn_devsq.rs | 26 ++ base/src/test/test_harmean.rs | 27 ++ docs/src/functions/statistical.md | 6 +- docs/src/functions/statistical/avedev.md | 3 +- docs/src/functions/statistical/devsq.md | 3 +- docs/src/functions/statistical/harmean.md | 3 +- 11 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 base/src/test/test_fn_avedev.rs create mode 100644 base/src/test/test_fn_devsq.rs create mode 100644 base/src/test/test_harmean.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..93f6c034b 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::Harmean => vec![Signature::Vector; arg_count], + Function::Avedev => vec![Signature::Vector; arg_count], + Function::Devsq => 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::Harmean => not_implemented(args), + Function::Avedev => not_implemented(args), + Function::Devsq => not_implemented(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..07bc99470 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -145,6 +145,9 @@ pub enum Function { Maxifs, Minifs, Geomean, + Harmean, + Avedev, + Devsq, // 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::Harmean, + Function::Avedev, + Function::Devsq, 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), + "HARMEAN" => Some(Function::Harmean), + "AVEDEV" => Some(Function::Avedev), + "DEVSQ" => Some(Function::Devsq), // 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::Harmean => write!(f, "HARMEAN"), + Function::Avedev => write!(f, "AVEDEV"), + Function::Devsq => write!(f, "DEVSQ"), 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::Harmean => self.fn_harmean(args, cell), + Function::Avedev => self.fn_avedev(args, cell), + Function::Devsq => self.fn_devsq(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..145554d7a 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -730,4 +730,255 @@ impl Model { } CalcResult::Number(product.powf(1.0 / count)) } + + pub(crate) fn fn_harmean(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut count = 0.0; + let mut sum_reciprocal = 0.0; + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + if value <= 0.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "Data points must be > 0".to_string(), + ); + } + count += 1.0; + sum_reciprocal += 1.0 / value; + } + CalcResult::Boolean(b) => { + if !matches!(arg, Node::ReferenceKind { .. }) { + let value = if b { 1.0 } else { 0.0 }; + if value <= 0.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "Data points must be > 0".to_string(), + ); + } + count += 1.0; + sum_reciprocal += 1.0 / value; + } + } + 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 { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => { + if value <= 0.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "Data points must be > 0".to_string(), + ); + } + count += 1.0; + sum_reciprocal += 1.0 / 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 !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + if t <= 0.0 { + return CalcResult::new_error( + Error::NUM, + cell, + "Data points must be > 0".to_string(), + ); + } + count += 1.0; + sum_reciprocal += 1.0 / t; + } else { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument cannot be cast into number".to_string(), + }; + } + } + } + _ => {} + } + } + if count == 0.0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + CalcResult::Number(count / sum_reciprocal) + } + + pub(crate) fn fn_avedev(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut values: Vec = Vec::new(); + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => values.push(value), + 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 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 self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => values.push(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 !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument cannot be cast into number".to_string(), + }; + } + } + } + _ => {} + } + } + if values.is_empty() { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + let count = values.len() as f64; + let mean: f64 = values.iter().sum::() / count; + let total: f64 = values.iter().map(|v| (*v - mean).abs()).sum(); + CalcResult::Number(total / count) + } + + pub(crate) fn fn_devsq(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + let mut values: Vec = Vec::new(); + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => values.push(value), + 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 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 self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => values.push(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 !matches!(arg, Node::ReferenceKind { .. }) { + if let Ok(t) = s.parse::() { + values.push(t); + } else { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument cannot be cast into number".to_string(), + }; + } + } + } + _ => {} + } + } + if values.is_empty() { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + let count = values.len() as f64; + let mean: f64 = values.iter().sum::() / count; + let total: f64 = values.iter().map(|v| (*v - mean).powi(2)).sum(); + CalcResult::Number(total) + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..3f5a0f700 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -54,11 +54,14 @@ mod test_number_format; mod test_arrays; mod test_escape_quotes; mod test_extend; +mod test_fn_avedev; +mod test_fn_devsq; mod test_fn_fv; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; mod test_get_cell_content; +mod test_harmean; mod test_implicit_intersection; mod test_issue_155; mod test_ln; diff --git a/base/src/test/test_fn_avedev.rs b/base/src/test/test_fn_avedev.rs new file mode 100644 index 000000000..de0e2e553 --- /dev/null +++ b/base/src/test/test_fn_avedev.rs @@ -0,0 +1,26 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_avedev_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=AVEDEV()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_avedev_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + model._set("B6", "true"); + model._set("A1", "=AVEDEV(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0.666666667"); +} diff --git a/base/src/test/test_fn_devsq.rs b/base/src/test/test_fn_devsq.rs new file mode 100644 index 000000000..f2ae93ca9 --- /dev/null +++ b/base/src/test/test_fn_devsq.rs @@ -0,0 +1,26 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_devsq_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=DEVSQ()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_devsq_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + model._set("B6", "true"); + model._set("A1", "=DEVSQ(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); +} diff --git a/base/src/test/test_harmean.rs b/base/src/test/test_harmean.rs new file mode 100644 index 000000000..b582cdc55 --- /dev/null +++ b/base/src/test/test_harmean.rs @@ -0,0 +1,27 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_harmean_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=HARMEAN()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_harmean_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 is empty + model._set("B6", "true"); + model._set("A1", "=HARMEAN(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.636363636"); +} diff --git a/docs/src/functions/statistical.md b/docs/src/functions/statistical.md index 6842212c3..539a335f1 100644 --- a/docs/src/functions/statistical.md +++ b/docs/src/functions/statistical.md @@ -11,7 +11,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ------------------------ |--------------------------------------------------| ------------- | -| AVEDEV | | – | +| AVEDEV | | – | | AVERAGE | | – | | AVERAGEA | | – | | AVERAGEIF | | – | @@ -36,7 +36,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | COUNTIFS | | – | | COVARIANCE.P | | – | | COVARIANCE.S | | – | -| DEVSQ | | – | +| DEVSQ | | – | | EXPON.DIST | | – | | F.DIST | | – | | F.DIST.RT | | – | @@ -60,7 +60,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | GAUSS | | – | | GEOMEAN | | – | | GROWTH | | – | -| HARMEAN | | – | +| HARMEAN | | – | | HYPGEOM.DIST | | – | | INTERCEPT | | – | | KURT | | – | diff --git a/docs/src/functions/statistical/avedev.md b/docs/src/functions/statistical/avedev.md index 050894abb..004e5ddac 100644 --- a/docs/src/functions/statistical/avedev.md +++ b/docs/src/functions/statistical/avedev.md @@ -7,6 +7,5 @@ lang: en-US # AVEDEV ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/devsq.md b/docs/src/functions/statistical/devsq.md index 4c3c851ff..62e526562 100644 --- a/docs/src/functions/statistical/devsq.md +++ b/docs/src/functions/statistical/devsq.md @@ -7,6 +7,5 @@ lang: en-US # DEVSQ ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/statistical/harmean.md b/docs/src/functions/statistical/harmean.md index b48bd516c..d004b9f89 100644 --- a/docs/src/functions/statistical/harmean.md +++ b/docs/src/functions/statistical/harmean.md @@ -7,6 +7,5 @@ lang: en-US # HARMEAN ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 866e14950bb2232ea27c1650906d45de97911fba Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sat, 2 Aug 2025 02:00:49 -0700 Subject: [PATCH 2/2] refactor --- .../src/expressions/parser/static_analysis.rs | 14 +- base/src/functions/statistical.rs | 338 +++++++----------- base/src/test/mod.rs | 2 +- base/src/test/test_fn_avedev.rs | 27 ++ base/src/test/test_fn_devsq.rs | 41 ++- base/src/test/test_fn_harmean.rs | 56 +++ base/src/test/test_harmean.rs | 27 -- 7 files changed, 255 insertions(+), 250 deletions(-) create mode 100644 base/src/test/test_fn_harmean.rs delete mode 100644 base/src/test/test_harmean.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 93f6c034b..0ac595996 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -784,10 +784,9 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Formulatext => args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), - Function::Geomean => vec![Signature::Vector; arg_count], - Function::Harmean => vec![Signature::Vector; arg_count], - Function::Avedev => vec![Signature::Vector; arg_count], - Function::Devsq => vec![Signature::Vector; arg_count], + Function::Geomean | Function::Harmean | Function::Avedev | Function::Devsq => { + vec![Signature::Vector; arg_count] + } } } @@ -992,9 +991,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Randbetween => scalar_arguments(args), Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), - Function::Geomean => not_implemented(args), - Function::Harmean => not_implemented(args), - Function::Avedev => not_implemented(args), - Function::Devsq => not_implemented(args), + Function::Geomean | Function::Harmean | Function::Avedev | Function::Devsq => { + not_implemented(args) + } } } diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index 145554d7a..572cbdf5a 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -9,6 +9,108 @@ use crate::{ use super::util::build_criteria; +/// Helper function to extract numeric values from arguments, with optional positive-only validation +fn extract_numeric_values( + model: &mut Model, + args: &[Node], + cell: CellReferenceIndex, + positive_only: bool, +) -> Result, CalcResult> { + let mut values = Vec::new(); + + for arg in args { + match model.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + if positive_only && value <= 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Values must be positive".to_string(), + )); + } + values.push(value); + } + CalcResult::Boolean(b) => { + if let Node::ReferenceKind { .. } = arg { + // Skip booleans in cell references + } else { + let value = if b { 1.0 } else { 0.0 }; + if positive_only && value <= 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Values must be positive".to_string(), + )); + } + values.push(value); + } + } + 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(value) => { + if positive_only && value <= 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Values must be positive".to_string(), + )); + } + values.push(value); + } + 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 let Node::ReferenceKind { .. } = arg { + // Skip strings in cell references + } else if let Ok(value) = s.parse::() { + if positive_only && value <= 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Values must be positive".to_string(), + )); + } + values.push(value); + } else { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument cannot be cast into number".to_string(), + }); + } + } + _ => {} + } + } + + Ok(values) +} + impl Model { pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.is_empty() { @@ -735,168 +837,35 @@ impl Model { if args.is_empty() { return CalcResult::new_args_number_error(cell); } - let mut count = 0.0; - let mut sum_reciprocal = 0.0; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - if value <= 0.0 { - return CalcResult::new_error( - Error::NUM, - cell, - "Data points must be > 0".to_string(), - ); - } - count += 1.0; - sum_reciprocal += 1.0 / value; - } - CalcResult::Boolean(b) => { - if !matches!(arg, Node::ReferenceKind { .. }) { - let value = if b { 1.0 } else { 0.0 }; - if value <= 0.0 { - return CalcResult::new_error( - Error::NUM, - cell, - "Data points must be > 0".to_string(), - ); - } - count += 1.0; - sum_reciprocal += 1.0 / value; - } - } - 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 { - for column in left.column..=right.column { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - if value <= 0.0 { - return CalcResult::new_error( - Error::NUM, - cell, - "Data points must be > 0".to_string(), - ); - } - count += 1.0; - sum_reciprocal += 1.0 / 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 !matches!(arg, Node::ReferenceKind { .. }) { - if let Ok(t) = s.parse::() { - if t <= 0.0 { - return CalcResult::new_error( - Error::NUM, - cell, - "Data points must be > 0".to_string(), - ); - } - count += 1.0; - sum_reciprocal += 1.0 / t; - } else { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Argument cannot be cast into number".to_string(), - }; - } - } - } - _ => {} - } - } - if count == 0.0 { + + let values = match extract_numeric_values(self, args, cell, true) { + Ok(v) => v, + Err(error) => return error, + }; + + if values.is_empty() { return CalcResult::Error { error: Error::DIV, origin: cell, message: "Division by Zero".to_string(), }; } - CalcResult::Number(count / sum_reciprocal) + + let count = values.len() as f64; + let sum_recip: f64 = values.iter().map(|v| 1.0 / v).sum(); + CalcResult::Number(count / sum_recip) } pub(crate) fn fn_avedev(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.is_empty() { return CalcResult::new_args_number_error(cell); } - let mut values: Vec = Vec::new(); - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => values.push(value), - 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 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 self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => values.push(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 !matches!(arg, Node::ReferenceKind { .. }) { - if let Ok(t) = s.parse::() { - values.push(t); - } else { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Argument cannot be cast into number".to_string(), - }; - } - } - } - _ => {} - } - } + + let values = match extract_numeric_values(self, args, cell, false) { + Ok(v) => v, + Err(error) => return error, + }; + if values.is_empty() { return CalcResult::Error { error: Error::DIV, @@ -904,9 +873,10 @@ impl Model { message: "Division by Zero".to_string(), }; } + let count = values.len() as f64; let mean: f64 = values.iter().sum::() / count; - let total: f64 = values.iter().map(|v| (*v - mean).abs()).sum(); + let total: f64 = values.iter().map(|v| (v - mean).abs()).sum(); CalcResult::Number(total / count) } @@ -914,61 +884,12 @@ impl Model { if args.is_empty() { return CalcResult::new_args_number_error(cell); } - let mut values: Vec = Vec::new(); - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => values.push(value), - 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 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 self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => values.push(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 !matches!(arg, Node::ReferenceKind { .. }) { - if let Ok(t) = s.parse::() { - values.push(t); - } else { - return CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Argument cannot be cast into number".to_string(), - }; - } - } - } - _ => {} - } - } + + let values = match extract_numeric_values(self, args, cell, false) { + Ok(v) => v, + Err(error) => return error, + }; + if values.is_empty() { return CalcResult::Error { error: Error::DIV, @@ -976,9 +897,10 @@ impl Model { message: "Division by Zero".to_string(), }; } + let count = values.len() as f64; let mean: f64 = values.iter().sum::() / count; - let total: f64 = values.iter().map(|v| (*v - mean).powi(2)).sum(); - CalcResult::Number(total) + let devsq: f64 = values.iter().map(|v| (v - mean).powi(2)).sum(); + CalcResult::Number(devsq) } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3f5a0f700..4de849151 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -57,11 +57,11 @@ mod test_extend; mod test_fn_avedev; mod test_fn_devsq; mod test_fn_fv; +mod test_fn_harmean; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; mod test_get_cell_content; -mod test_harmean; mod test_implicit_intersection; mod test_issue_155; mod test_ln; diff --git a/base/src/test/test_fn_avedev.rs b/base/src/test/test_fn_avedev.rs index de0e2e553..d3cd23ce3 100644 --- a/base/src/test/test_fn_avedev.rs +++ b/base/src/test/test_fn_avedev.rs @@ -24,3 +24,30 @@ fn test_fn_avedev_minimal() { assert_eq!(model._get_text("A1"), *"0.666666667"); } + +#[test] +fn test_fn_avedev_mathematical_validation() { + let mut model = new_empty_model(); + // Test with values 2, 4, 6, 8 + // Mean = 5, deviations: |2-5|=3, |4-5|=1, |6-5|=1, |8-5|=3 + // Average deviation = (3+1+1+3)/4 = 2 + model._set("B1", "2"); + model._set("B2", "4"); + model._set("B3", "6"); + model._set("B4", "8"); + model._set("A1", "=AVEDEV(B1:B4)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); +} + +#[test] +fn test_fn_avedev_single_value() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("A1", "=AVEDEV(B1)"); + model.evaluate(); + + // Single value has zero deviation from its own mean + assert_eq!(model._get_text("A1"), *"0"); +} diff --git a/base/src/test/test_fn_devsq.rs b/base/src/test/test_fn_devsq.rs index f2ae93ca9..0eae6d33f 100644 --- a/base/src/test/test_fn_devsq.rs +++ b/base/src/test/test_fn_devsq.rs @@ -14,13 +14,42 @@ fn test_fn_devsq_arguments() { #[test] fn test_fn_devsq_minimal() { let mut model = new_empty_model(); + // Data from mathematical example: 4,5,8,7,11,4,3 -> result 48 + model._set("B1", "4"); + model._set("B2", "5"); + model._set("B3", "8"); + model._set("B4", "7"); + model._set("B5", "11"); + model._set("B6", "4"); + model._set("B7", "3"); + model._set("A1", "=DEVSQ(B1:B7)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"48"); +} + +#[test] +fn test_fn_devsq_simple_validation() { + let mut model = new_empty_model(); + // Test with values 1, 3, 5 + // Mean = 3, deviations: (1-3)²=4, (3-3)²=0, (5-3)²=4 + // Sum of squared deviations = 4+0+4 = 8 model._set("B1", "1"); - model._set("B2", "2"); - model._set("B3", "3"); - model._set("B4", "'2"); - model._set("B6", "true"); - model._set("A1", "=DEVSQ(B1:B6)"); + model._set("B2", "3"); + model._set("B3", "5"); + model._set("A1", "=DEVSQ(B1:B3)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"8"); +} + +#[test] +fn test_fn_devsq_single_value() { + let mut model = new_empty_model(); + model._set("B1", "10"); + model._set("A1", "=DEVSQ(B1)"); model.evaluate(); - assert_eq!(model._get_text("A1"), *"2"); + // Single value has zero squared deviation from its own mean + assert_eq!(model._get_text("A1"), *"0"); } diff --git a/base/src/test/test_fn_harmean.rs b/base/src/test/test_fn_harmean.rs new file mode 100644 index 000000000..f4844181c --- /dev/null +++ b/base/src/test/test_fn_harmean.rs @@ -0,0 +1,56 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_harmean_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=HARMEAN()"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_harmean_minimal() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("B4", "'2"); + // B5 is empty + model._set("B6", "true"); + model._set("A1", "=HARMEAN(B1:B6)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.636363636"); +} + +#[test] +fn test_fn_harmean_zero_and_negative() { + let mut model = new_empty_model(); + model._set("B1", "0"); + model._set("A1", "=HARMEAN(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + + model._set("B1", "-1"); + model._set("A1", "=HARMEAN(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); +} + +#[test] +fn test_fn_harmean_mathematical_validation() { + let mut model = new_empty_model(); + // Test with values 1, 2, 4 -> harmonic mean = 3/(1/1 + 1/2 + 1/4) = 3/1.75 ≈ 1.714 + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "4"); + model._set("A1", "=HARMEAN(B1:B3)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.714285714"); +} diff --git a/base/src/test/test_harmean.rs b/base/src/test/test_harmean.rs deleted file mode 100644 index b582cdc55..000000000 --- a/base/src/test/test_harmean.rs +++ /dev/null @@ -1,27 +0,0 @@ -#![allow(clippy::unwrap_used)] - -use crate::test::util::new_empty_model; - -#[test] -fn test_fn_harmean_arguments() { - let mut model = new_empty_model(); - model._set("A1", "=HARMEAN()"); - model.evaluate(); - - assert_eq!(model._get_text("A1"), *"#ERROR!"); -} - -#[test] -fn test_fn_harmean_minimal() { - let mut model = new_empty_model(); - model._set("B1", "1"); - model._set("B2", "2"); - model._set("B3", "3"); - model._set("B4", "'2"); - // B5 is empty - model._set("B6", "true"); - model._set("A1", "=HARMEAN(B1:B6)"); - model.evaluate(); - - assert_eq!(model._get_text("A1"), *"1.636363636"); -}