diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..0ac595996 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -784,7 +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::Geomean | Function::Harmean | Function::Avedev | Function::Devsq => { + vec![Signature::Vector; arg_count] + } } } @@ -989,6 +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::Geomean | Function::Harmean | Function::Avedev | 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..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() { @@ -730,4 +832,75 @@ 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 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(), + }; + } + + 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 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, + 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 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, + origin: cell, + message: "Division by Zero".to_string(), + }; + } + + let count = values.len() as f64; + let mean: f64 = values.iter().sum::() / count; + 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 a0a0d69d6..4de849151 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -54,7 +54,10 @@ 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_harmean; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; diff --git a/base/src/test/test_fn_avedev.rs b/base/src/test/test_fn_avedev.rs new file mode 100644 index 000000000..d3cd23ce3 --- /dev/null +++ b/base/src/test/test_fn_avedev.rs @@ -0,0 +1,53 @@ +#![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"); +} + +#[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 new file mode 100644 index 000000000..0eae6d33f --- /dev/null +++ b/base/src/test/test_fn_devsq.rs @@ -0,0 +1,55 @@ +#![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(); + // 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", "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(); + + // 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/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