From a330688465341a25e563ebede835a75d39593366 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:11:10 -0700 Subject: [PATCH 01/10] merge ceiling, floor #25 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mathematical.rs | 163 ++++++++++++------ base/src/functions/mod.rs | 12 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_ceiling_floor.rs | 144 ++++++++++++++++ docs/src/functions/math-and-trigonometry.md | 4 +- .../math_and_trigonometry/ceiling.md | 3 +- .../functions/math_and_trigonometry/floor.md | 3 +- 8 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 base/src/test/test_fn_ceiling_floor.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f19436..254ce0af 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -619,6 +619,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_no_args(arg_count), Function::Power => args_signature_scalars(arg_count, 2, 0), Function::Product => vec![Signature::Vector; arg_count], + Function::Ceiling => args_signature_scalars(arg_count, 2, 0), + Function::Floor => args_signature_scalars(arg_count, 2, 0), Function::Round => args_signature_scalars(arg_count, 2, 0), Function::Rounddown => args_signature_scalars(arg_count, 2, 0), Function::Roundup => args_signature_scalars(arg_count, 2, 0), @@ -820,6 +822,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Pi => StaticResult::Scalar, Function::Power => scalar_arguments(args), Function::Product => not_implemented(args), + Function::Ceiling => scalar_arguments(args), + Function::Floor => scalar_arguments(args), Function::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 8931b58d..733f2ec0 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -8,6 +8,21 @@ use crate::{ }; use std::f64::consts::PI; +/// Specifies which rounding behaviour to apply when calling `round_to_multiple`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RoundKind { + Ceiling, + Floor, +} + +/// Rounding mode used by the classic ROUND family (ROUND, ROUNDUP, ROUNDDOWN). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RoundDecimalKind { + Nearest, // ROUND + Up, // ROUNDUP + Down, // ROUNDDOWN +} + #[cfg(not(target_arch = "wasm32"))] pub fn random() -> f64 { rand::random() @@ -305,77 +320,123 @@ impl Model { CalcResult::Number(total) } - pub(crate) fn fn_round(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + /// Shared implementation for Excel's ROUND / ROUNDUP / ROUNDDOWN functions + /// that round a scalar to a specified number of decimal digits. + fn round_decimal_fn( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + mode: RoundDecimalKind, + ) -> CalcResult { if args.len() != 2 { - // Incorrect number of arguments return CalcResult::new_args_number_error(cell); } + + // Extract value and number_of_digits, propagating errors. let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + Ok(v) => v, + Err(e) => return e, + }; + let digits_raw = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() + + // Excel truncates non-integer digit counts toward zero. + let digits = if digits_raw > 0.0 { + digits_raw.floor() + } else { + digits_raw.ceil() + }; + + let scale = 10.0_f64.powf(digits); + + let rounded = match mode { + RoundDecimalKind::Nearest => (value * scale).round() / scale, + RoundDecimalKind::Up => { + if value > 0.0 { + (value * scale).ceil() / scale } else { - f.ceil() + (value * scale).floor() / scale } } - Err(s) => return s, - }; - let scale = 10.0_f64.powf(number_of_digits); - CalcResult::Number((value * scale).round() / scale) - } - pub(crate) fn fn_roundup(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() + RoundDecimalKind::Down => { + if value > 0.0 { + (value * scale).floor() / scale } else { - f.ceil() + (value * scale).ceil() / scale } } - Err(s) => return s, }; - let scale = 10.0_f64.powf(number_of_digits); - if value > 0.0 { - CalcResult::Number((value * scale).ceil() / scale) - } else { - CalcResult::Number((value * scale).floor() / scale) - } + + CalcResult::Number(rounded) } - pub(crate) fn fn_rounddown(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + + /// Helper used by CEILING and FLOOR to round a value to the nearest multiple of + /// `significance`, taking into account the Excel sign rule. + fn round_to_multiple( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + kind: RoundKind, + ) -> CalcResult { if args.len() != 2 { return CalcResult::new_args_number_error(cell); } + let value = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + Ok(v) => v, + Err(e) => return e, }; - let number_of_digits = match self.get_number(&args[1], cell) { - Ok(f) => { - if f > 0.0 { - f.floor() - } else { - f.ceil() - } - } - Err(s) => return s, + let significance = match self.get_number(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, }; - let scale = 10.0_f64.powf(number_of_digits); - if value > 0.0 { - CalcResult::Number((value * scale).floor() / scale) - } else { - CalcResult::Number((value * scale).ceil() / scale) + + if significance == 0.0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "Divide by 0".to_string(), + }; + } + if value.signum() * significance.signum() < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid sign".to_string(), + }; } + + let quotient = value / significance; + let use_ceil = (significance > 0.0) == matches!(kind, RoundKind::Ceiling); + let rounded_multiple = if use_ceil { + quotient.ceil() * significance + } else { + quotient.floor() * significance + }; + + CalcResult::Number(rounded_multiple) + } + + pub(crate) fn fn_ceiling(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_to_multiple(args, cell, RoundKind::Ceiling) + } + + pub(crate) fn fn_floor(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_to_multiple(args, cell, RoundKind::Floor) + } + + pub(crate) fn fn_round(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Nearest) + } + + pub(crate) fn fn_roundup(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Up) + } + + pub(crate) fn fn_rounddown(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.round_decimal_fn(args, cell, RoundDecimalKind::Down) } single_number_fn!(fn_log10, |f| if f <= 0.0 { diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72d..fc75e3a4 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -64,6 +64,8 @@ pub enum Function { Product, Rand, Randbetween, + Ceiling, + Floor, Round, Rounddown, Roundup, @@ -253,7 +255,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -292,6 +294,8 @@ impl Function { Function::Product, Function::Rand, Function::Randbetween, + Function::Ceiling, + Function::Floor, Function::Round, Function::Rounddown, Function::Roundup, @@ -549,6 +553,8 @@ impl Function { "PRODUCT" => Some(Function::Product), "RAND" => Some(Function::Rand), "RANDBETWEEN" => Some(Function::Randbetween), + "CEILING" => Some(Function::Ceiling), + "FLOOR" => Some(Function::Floor), "ROUND" => Some(Function::Round), "ROUNDDOWN" => Some(Function::Rounddown), "ROUNDUP" => Some(Function::Roundup), @@ -770,6 +776,8 @@ impl fmt::Display for Function { Function::Product => write!(f, "PRODUCT"), Function::Rand => write!(f, "RAND"), Function::Randbetween => write!(f, "RANDBETWEEN"), + Function::Ceiling => write!(f, "CEILING"), + Function::Floor => write!(f, "FLOOR"), Function::Round => write!(f, "ROUND"), Function::Rounddown => write!(f, "ROUNDDOWN"), Function::Roundup => write!(f, "ROUNDUP"), @@ -1006,6 +1014,8 @@ impl Model { Function::Product => self.fn_product(args, cell), Function::Rand => self.fn_rand(args, cell), Function::Randbetween => self.fn_randbetween(args, cell), + Function::Ceiling => self.fn_ceiling(args, cell), + Function::Floor => self.fn_floor(args, cell), Function::Round => self.fn_round(args, cell), Function::Rounddown => self.fn_rounddown(args, cell), Function::Roundup => self.fn_roundup(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d..3441b012 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -10,6 +10,7 @@ mod test_date_and_time; mod test_error_propagation; mod test_fn_average; mod test_fn_averageifs; +mod test_fn_ceiling_floor; mod test_fn_choose; mod test_fn_concatenate; mod test_fn_count; diff --git a/base/src/test/test_fn_ceiling_floor.rs b/base/src/test/test_fn_ceiling_floor.rs new file mode 100644 index 00000000..ca0d876b --- /dev/null +++ b/base/src/test/test_fn_ceiling_floor.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn simple_cases() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,2)"); + model._set("A2", "=CEILING(-4.3,-2)"); + model._set("A3", "=CEILING(-4.3,2)"); + model._set("B1", "=FLOOR(4.3,2)"); + model._set("B2", "=FLOOR(-4.3,-2)"); + model._set("B3", "=FLOOR(4.3,-2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("A2"), *"-4"); + assert_eq!(model._get_text("A3"), *"#NUM!"); + assert_eq!(model._get_text("B1"), *"4"); + assert_eq!(model._get_text("B2"), *"-6"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn wrong_number_of_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(1)"); + model._set("A2", "=CEILING(1,2,3)"); + model._set("B1", "=FLOOR(1)"); + model._set("B2", "=FLOOR(1,2,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); +} + +#[test] +fn zero_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,0)"); + model._set("B1", "=FLOOR(4.3,0)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#DIV/0!"); + assert_eq!(model._get_text("B1"), *"#DIV/0!"); +} + +#[test] +fn already_multiple() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(6,3)"); + model._set("B1", "=FLOOR(6,3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("B1"), *"6"); +} + +#[test] +fn smaller_than_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(1.3,2)"); + model._set("B1", "=FLOOR(1.3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("B1"), *"0"); +} + +#[test] +fn fractional_significance() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,2.5)"); + model._set("B1", "=FLOOR(4.3,2.5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"5"); + assert_eq!(model._get_text("B1"), *"2.5"); +} + +#[test] +fn opposite_sign_error() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(4.3,-2)"); + model._set("B1", "=FLOOR(-4.3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("B1"), *"#NUM!"); +} + +#[test] +fn zero_value() { + let mut model = new_empty_model(); + model._set("A1", "=CEILING(0,2)"); + model._set("B1", "=FLOOR(0,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("B1"), *"0"); +} + +#[test] +fn coercion_cases() { + let mut model = new_empty_model(); + model._set("B1", "'4.3"); // text that can be coerced + model._set("B2", "TRUE"); // boolean + // B3 left blank + + model._set("C1", "=CEILING(B1,2)"); + model._set("C2", "=FLOOR(B2,1)"); + model._set("C3", "=CEILING(B3,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"6"); + assert_eq!(model._get_text("C2"), *"1"); + assert_eq!(model._get_text("C3"), *"0"); +} + +#[test] +fn error_propagation() { + let mut model = new_empty_model(); + model._set("A1", "=1/0"); // #DIV/0! in value + model._set("A2", "#REF!"); // #REF! error literal as significance + + model._set("B1", "=CEILING(A1,2)"); + model._set("B2", "=FLOOR(4.3,A2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#DIV/0!"); + assert_eq!(model._get_text("B2"), *"#REF!"); +} diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 03593584..33d6d2f6 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -24,7 +24,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | ATAN2 | | – | | ATANH | | – | | BASE | | – | -| CEILING | | – | +| CEILING | | [CEILING](math_and_trigonometry/ceiling) | | CEILING.MATH | | – | | CEILING.PRECISE | | – | | COMBIN | | – | @@ -41,7 +41,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | EXP | | – | | FACT | | – | | FACTDOUBLE | | – | -| FLOOR | | – | +| FLOOR | | [FLOOR](math_and_trigonometry/floor) | | FLOOR.MATH | | – | | FLOOR.PRECISE | | – | | GCD | | – | diff --git a/docs/src/functions/math_and_trigonometry/ceiling.md b/docs/src/functions/math_and_trigonometry/ceiling.md index e6640045..5ddc750c 100644 --- a/docs/src/functions/math_and_trigonometry/ceiling.md +++ b/docs/src/functions/math_and_trigonometry/ceiling.md @@ -7,6 +7,5 @@ lang: en-US # CEILING ::: 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/math_and_trigonometry/floor.md b/docs/src/functions/math_and_trigonometry/floor.md index 6e49e257..e5c739cd 100644 --- a/docs/src/functions/math_and_trigonometry/floor.md +++ b/docs/src/functions/math_and_trigonometry/floor.md @@ -7,6 +7,5 @@ lang: en-US # FLOOR ::: 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 210806b1fdba00f923752ef524d5fece28e28063 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:12:44 -0700 Subject: [PATCH 02/10] merge int, mround #31 --- .../src/expressions/parser/static_analysis.rs | 4 ++ base/src/functions/mathematical.rs | 34 ++++++++++ base/src/functions/mod.rs | 12 +++- base/src/test/mod.rs | 2 + base/src/test/test_int.rs | 48 ++++++++++++++ base/src/test/test_mround.rs | 62 ++++++++++++++++++ docs/src/functions/math-and-trigonometry.md | 4 +- .../functions/math_and_trigonometry/int.md | 3 +- .../functions/math_and_trigonometry/mround.md | 3 +- xlsx/tests/calc_tests/INT.xlsx | Bin 0 -> 8665 bytes xlsx/tests/calc_tests/MROUND.xlsx | Bin 0 -> 6409 bytes 11 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 base/src/test/test_int.rs create mode 100644 base/src/test/test_mround.rs create mode 100644 xlsx/tests/calc_tests/INT.xlsx create mode 100644 xlsx/tests/calc_tests/MROUND.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 254ce0af..4d1df1f7 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -784,6 +784,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_npv(arg_count), Function::Rand => args_signature_no_args(arg_count), Function::Randbetween => args_signature_scalars(arg_count, 2, 0), + Function::Int => args_signature_scalars(arg_count, 1, 0), + Function::Mround => 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], @@ -991,6 +993,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Subtotal => not_implemented(args), Function::Rand => not_implemented(args), Function::Randbetween => scalar_arguments(args), + Function::Int => scalar_arguments(args), + Function::Mround => scalar_arguments(args), Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 733f2ec0..6d769d55 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -613,4 +613,38 @@ impl Model { } CalcResult::Number((x + random() * (y - x)).floor()) } + + single_number_fn!(fn_int, |f: f64| Ok(f.floor())); + + pub(crate) fn fn_mround(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let significance = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + if significance == 0.0 { + return CalcResult::Number(0.0); + } + if (number > 0.0 && significance < 0.0) || (number < 0.0 && significance > 0.0) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "number and significance must have the same sign".to_string(), + }; + } + let abs_sign = significance.abs(); + let quotient = number / abs_sign; + let rounded = if quotient >= 0.0 { + (quotient + 0.5).floor() + } else { + (quotient - 0.5).ceil() + }; + CalcResult::Number(rounded * abs_sign) + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index fc75e3a4..6842cd15 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -57,6 +57,7 @@ pub enum Function { Log, Log10, Ln, + Int, Max, Min, Pi, @@ -66,6 +67,7 @@ pub enum Function { Randbetween, Ceiling, Floor, + Mround, Round, Rounddown, Roundup, @@ -255,7 +257,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -283,6 +285,7 @@ impl Function { Function::Abs, Function::Pi, Function::Ln, + Function::Int, Function::Log, Function::Log10, Function::Sqrt, @@ -296,6 +299,7 @@ impl Function { Function::Randbetween, Function::Ceiling, Function::Floor, + Function::Mround, Function::Round, Function::Rounddown, Function::Roundup, @@ -545,6 +549,7 @@ impl Function { "ATAN2" => Some(Function::Atan2), "LN" => Some(Function::Ln), + "INT" => Some(Function::Int), "LOG" => Some(Function::Log), "LOG10" => Some(Function::Log10), @@ -555,6 +560,7 @@ impl Function { "RANDBETWEEN" => Some(Function::Randbetween), "CEILING" => Some(Function::Ceiling), "FLOOR" => Some(Function::Floor), + "MROUND" => Some(Function::Mround), "ROUND" => Some(Function::Round), "ROUNDDOWN" => Some(Function::Rounddown), "ROUNDUP" => Some(Function::Roundup), @@ -753,6 +759,7 @@ impl fmt::Display for Function { Function::Log => write!(f, "LOG"), Function::Log10 => write!(f, "LOG10"), Function::Ln => write!(f, "LN"), + Function::Int => write!(f, "INT"), Function::Sin => write!(f, "SIN"), Function::Cos => write!(f, "COS"), Function::Tan => write!(f, "TAN"), @@ -778,6 +785,7 @@ impl fmt::Display for Function { Function::Randbetween => write!(f, "RANDBETWEEN"), Function::Ceiling => write!(f, "CEILING"), Function::Floor => write!(f, "FLOOR"), + Function::Mround => write!(f, "MROUND"), Function::Round => write!(f, "ROUND"), Function::Rounddown => write!(f, "ROUNDDOWN"), Function::Roundup => write!(f, "ROUNDUP"), @@ -985,6 +993,7 @@ impl Model { Function::Log => self.fn_log(args, cell), Function::Log10 => self.fn_log10(args, cell), Function::Ln => self.fn_ln(args, cell), + Function::Int => self.fn_int(args, cell), Function::Sin => self.fn_sin(args, cell), Function::Cos => self.fn_cos(args, cell), Function::Tan => self.fn_tan(args, cell), @@ -1016,6 +1025,7 @@ impl Model { Function::Randbetween => self.fn_randbetween(args, cell), Function::Ceiling => self.fn_ceiling(args, cell), Function::Floor => self.fn_floor(args, cell), + Function::Mround => self.fn_mround(args, cell), Function::Round => self.fn_round(args, cell), Function::Rounddown => self.fn_rounddown(args, cell), Function::Roundup => self.fn_roundup(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3441b012..fe019e6f 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -61,10 +61,12 @@ mod test_frozen_rows_and_columns; mod test_geomean; mod test_get_cell_content; mod test_implicit_intersection; +mod test_int; mod test_issue_155; mod test_ln; mod test_log; mod test_log10; +mod test_mround; mod test_percentage; mod test_set_functions_error_handling; mod test_today; diff --git a/base/src/test/test_int.rs b/base/src/test/test_int.rs new file mode 100644 index 00000000..bb334053 --- /dev/null +++ b/base/src/test/test_int.rs @@ -0,0 +1,48 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn int_wrong_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=INT()"); + model._set("A2", "=INT(5.7, 2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn int_basic_floor_behavior() { + let mut model = new_empty_model(); + // INT returns the largest integer less than or equal to the number (floor function) + model._set("A1", "=INT(5.7)"); // 5 + model._set("A2", "=INT(3.9)"); // 3 + model._set("A3", "=INT(5)"); // whole numbers unchanged + model._set("A4", "=INT(0)"); // zero + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"5"); + assert_eq!(model._get_text("A2"), *"3"); + assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"0"); +} + +#[test] +fn int_negative_floor_behavior() { + let mut model = new_empty_model(); + // Critical: INT floors towards negative infinity, not towards zero + // This is different from truncation behavior + model._set("A1", "=INT(-5.7)"); // -6 (not -5) + model._set("A2", "=INT(-3.1)"); // -4 (not -3) + model._set("A3", "=INT(-0.9)"); // -1 (not 0) + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"-6"); + assert_eq!(model._get_text("A2"), *"-4"); + assert_eq!(model._get_text("A3"), *"-1"); +} diff --git a/base/src/test/test_mround.rs b/base/src/test/test_mround.rs new file mode 100644 index 00000000..f5ca55ac --- /dev/null +++ b/base/src/test/test_mround.rs @@ -0,0 +1,62 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn mround_wrong_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=MROUND()"); + model._set("A2", "=MROUND(10)"); + model._set("A3", "=MROUND(10, 3, 5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); +} + +#[test] +fn mround_basic_rounding() { + let mut model = new_empty_model(); + // MROUND rounds to nearest multiple of significance + model._set("A1", "=MROUND(10, 3)"); // 9 (closest multiple of 3) + model._set("A2", "=MROUND(11, 3)"); // 12 (rounds up at midpoint) + model._set("A3", "=MROUND(1.3, 0.2)"); // 1.4 (decimal significance) + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"9"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"1.4"); +} + +#[test] +fn mround_sign_validation() { + let mut model = new_empty_model(); + // Critical: number and significance must have same sign + model._set("A1", "=MROUND(10, -3)"); // positive number, negative significance + model._set("A2", "=MROUND(-10, 3)"); // negative number, positive significance + model._set("A3", "=MROUND(-10, -3)"); // both negative - valid + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"-9"); +} + +#[test] +fn mround_special_cases() { + let mut model = new_empty_model(); + // Zero significance always returns 0 + model._set("A1", "=MROUND(10, 0)"); + model._set("A2", "=MROUND(0, 5)"); // zero rounds to zero + model._set("A3", "=MROUND(2.5, 5)"); // midpoint rounding (rounds up) + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"5"); +} diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 33d6d2f6..b17add03 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -45,7 +45,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | FLOOR.MATH | | – | | FLOOR.PRECISE | | – | | GCD | | – | -| INT | | – | +| INT | | [INT](math_and_trigonometry/int) | | ISO.CEILING | | – | | LCM | | – | | LET | | – | @@ -56,7 +56,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MINVERSE | | – | | MMULT | | – | | MOD | | – | -| MROUND | | – | +| MROUND | | [MROUND](math_and_trigonometry/mround) | | MULTINOMIAL | | – | | MUNIT | | – | | ODD | | – | diff --git a/docs/src/functions/math_and_trigonometry/int.md b/docs/src/functions/math_and_trigonometry/int.md index a4e46932..916eb019 100644 --- a/docs/src/functions/math_and_trigonometry/int.md +++ b/docs/src/functions/math_and_trigonometry/int.md @@ -7,6 +7,5 @@ lang: en-US # INT ::: 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/int-function-881735da-3799-4681-8618-92b5c4f46c4a). ::: \ No newline at end of file diff --git a/docs/src/functions/math_and_trigonometry/mround.md b/docs/src/functions/math_and_trigonometry/mround.md index ca02f76c..2c8e09db 100644 --- a/docs/src/functions/math_and_trigonometry/mround.md +++ b/docs/src/functions/math_and_trigonometry/mround.md @@ -7,6 +7,5 @@ lang: en-US # MROUND ::: 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/mround-function-c299c2b1-15ed-4261-8a0f-4b0a023685b8). ::: \ No newline at end of file diff --git a/xlsx/tests/calc_tests/INT.xlsx b/xlsx/tests/calc_tests/INT.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d6d1946ca03f104cb7d7da8e82272e436f3693b3 GIT binary patch literal 8665 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 literal 0 HcmV?d00001 diff --git a/xlsx/tests/calc_tests/MROUND.xlsx b/xlsx/tests/calc_tests/MROUND.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..47088fbf5803c8a2da11b7c7f526ac7f2d9af322 GIT binary patch literal 6409 zcmbuDcT`j9*2Y7X-kYI`KoBX?l->;>C4yAxBSb)fARrwD1f&a6M5=V81*8TL3|(3T zDbl-uNQWr>3wrMechs3#U)DNj<&T_So_C*Tzk9!1Qw;}~2ypztrC%}p_T@h}8~_u* z&GwS64j}-i^`5i!1m?Q$Mhw8h{r2nQo8~VcCj0)dE6nk(lQk6T?DT_k%=!t32E!u& zkYhxIwq%)J#s&c9@BjdYzi__c1o_?UY`-ZR!_eCQO7xB~iB1rp30&Rdx$wF;S>A7B zh}qogo>k6khnks)<@-&KZqrdQrdJ%*5vtPVdVssr$*BWKUb zIaPq1qr=eY49XdMTZ&$m`-S7!1d1QuO9)mfd|si*+vL-!su&{64WkLuH&BU&X}+HE z8Pw3yO@BFqo7>=@U^DvF)D2kr`T}79y?x(aUc){NpHmX_vB{R1_|;HIi*K*nW6?FD zTZm}9-mBH91Rc44T>Qu91zAC^rMWMyg&t6e4J4PQ1>O`yXNRpWH95zCF0j$(;l(|{ z>3qSf%k^^h3*zMtnQrWu)5zh==Vt9jQ^4B=`{03FiSai&fkd^Jn*EsTk?x=)lWBH3 zv2bxopfPD;3qs;TraFg?NpJpS?{$uAna*Z~KCLP$r0tJ3oWL%H=^#c`qNf%KjRDm| z<<&dWts6TfX1*1D4)k>I_AFebEK9g>ZkmShS3+dZFzur&2)sex9(W zy1wuj|9e(}8l?FfXHWIP_R%=;V17ku(rpRv)+3GKP%`Ch4+B(tEuCbYP(HlmP@~}C zjNf&^g%WR`O(S&9{M`Nw)a%8%{Fl6Ctn*7s%$^by6kG3nWASz?>{f-u`X-AdfdDX|Vx(eTc9y5uxgBZso!4&U6;xSFaX;)b|fP%LhNk!w3q)sfdQgN)&f`zuIUe zksw{VSd0bP4EmWW9qY063584rKxJukv8F?dvcI-7;ddLFIYMnuIQbVD@!iST7$*xa z%~Tg+e7%PS002)Sj`QLc40iX}#{9?7Z(eS~`1ttxpX?})k|uj3KoS6_ngcN8_N8HG z^B2(42l?$#`d%%`Z&+qT0#ul*1#Y6^>c3c8T@nfLl++rOq}sFwwL@D!vj8$BnP|ZS zQ#7FP?TqPj-o%ha`EmjqrU@kL*;j25>RNncM{L8{ioi@t(1e0m`=>%x-p?kh;9PX`s{C48GFrHj!IJ+fpUn~W~&BQB$qs|wjBx0a@f zk7M{F0KItYN;fd6tc=-WNWTZb$=Tf&>hx#+{$kBzdLtnt0u%?dGvv9^@d;HN?zwE5 z)*THiie_^L5ZvmjSG61myLUL|D-vgJ?CK>>J{Ok|w#pRj&U58@n@XNkGn<>aGPsCD ztZ2t=*Wl^*bZ#m855bqVCLV}tztnv-|vvFoqI$}->Lj8GXfFw zt|ic#uESq>m719Q(nUHO5?eiHrwF6h+YpX`1y9>DGf1+2N#$n|GcR=*!_-tqU(1!z z@p4AR#Ms$F_CVS_MS-Nn1ue$;u=DwpwlI-U)$X#MHH52iyOji-b=kz%BC#p_sMlXQ zCjynNe>p+0YeCa@#945setx+r|6b~uiAb_T6jJQa(w!#)DO&-o9S-UwQzuG>$#+b!>hq@f7&AQ)K1*A79?pa_=(yk9z8W5$ zUxe0|Xm`JttZk;g5XkDT9bPmxh*Yng>WI)cuMMb}!ltkUQ^?^q7;55Mf>{c-WzpqL z-e8K0#xj9%@#RegZJq?WQ-S4x{4IKG1n*|;g-Nttzr|VF(Z=)_tSSVwRA&a#?>wy8 zq{a~tyz(%f)U7?*0>>zYLF>atQ#Um(I#LWuhz}SzPbNhprp1BXm#&3VAeG}3NHld< z1}m1+-&zH^MTa+>>u2v^y2jbQ`3jf9S57HKG05}-Awl~&Hj4JmuWa6%(jDCsh|jx& z_cj?%8*{e!?uq~=D=%QoN&ch8{5L0mmyY3)J&;BL67a*U_A~G7{jRAmna2BEBcM?9 zL>ao4$2G;y*cZdOu5qhR2XM6x4!nJBniNDXnXZWbBYe||Uzex1Yjaee&tMszi$i~# zw4|TPaD2W`!~hwRH|`hhWk^$+gtV4-tZVMh_eHbDtui<-8v#mg%iRoz@X=?K>UVE+ zMBwIq)z-;)1s25VYd8szzi#f#N}$F1aL)4@V=(;DTXz4sw55QR9)dMhVkv=b=4$T- ze})}!gmXyAZHUnY-E<}5w1OpM1a3E2EX9Iht@;oY5f219Ys#L9c=CuMkBB~;Gb(#f zG?oB~Wvt=@>AcGy+~?ANW)@GN$KmW1GgHY-#Ro z%4;q6PDjfSW_GS@j#c<8ZHG=cGy{uTUmk=AD%;qxZQdUqmw}=dC76YL?FASO&OPjg z<;+41gQj#En%>;8lGVH0+S9AzJE>KZ(vZAmTc0UUSe9Y)&hi6#xF{++FI@Zj`!C)TONrJ=MBzr) z-`{l|dmj;hr zI(u>AOu2XO#{h?zYsTb_*9=y~gDDSNnsEngde6p*C^;ra;*kMFn|jnWz2Nc{A(IVH zdyOLpP~7F3K!Sue-)&_-w&qdz!S=J7x0%8o$=(v(cViyfapEBlcm&y)~y}QcxFJ0^^9RcB=0hB z^MG6IoAtsZDQ0S_t{7r``M_r!AgY(*4>VM$4|%eN50l=@t;Jm8*j0^sVpGbV;|%Ir z5C$TZ;EeYlTlY$N9OCUHT_$3RVr0g)Vk}i+^S7y4V}Wx-BosDK@+ipMI(r4^SWf3# zaqp$Sxgk|cj~k@7jB@{G!R2Tf^R~}jQTMwCqR1$pnu=n6+Z`H7S|pc?rnv9A8ItNWN*~8 zS0p$*D8hfiLf^5gtyKpe&HX_<_08u4Ynhbw<=r6;e|W-8K6biDHQ!Qsj;j68_O**m zgbS8NoP-@Z@1de0R4EFK_KVuvs);>;ts7WT#sU2{RB795_)bT9QV@PfnqGh?%vo~JV- z+0b%(IJfIryYn~18*JjKt@7K)p(RLNW|XecMseL8=`vaf>hNY^TPg`9gpQa>#Uh42 zTQz<2tajZz&$T99v3js@1=TZ#+L@27_aNF;R4x_d*g}XJ@s}P@L96VI2|f}+UZbF! zRMQ$J%l-3-Nkty#U3QakZJ8(*JwT7m?Ak|5s;DQqH&bFcgR$9T znegf8g=RHZo<`zRV|B&6y}E|&XPM396TeIRdV>VPP1{{4BmNM~`zR1%+RI5r6H-iq z$42isv$`66$2Dwlm)Fw?Ot=HG0@5 z^Kl)GnD$AR(Mv0BW;~x{DShme=uy=GE;uq6VA&Cx5O)}n7{@XFNX@I5`2gu165>Od zf2*zr;G-FrzIIvnMpS3bRWJ zTZRQm+#3;@raF)i7xE;eSXY6x+MY{b8?8jzu%t`@OlPjK5)iTJO$L?s6#PMx{oo3S zJ_~tsQC2~kkOaGbHg}4^J+3hvO2mISB)};=q%5gzG6)@WoDK|kbo2D^dLDP(j{2sk zN^l;DDoG}S^iFMC>Rz=Utof7BUcTIILG?&4bQz@R?UG~Zm^4Qo1^9W$o=E*jutFRU zcl(!~?TjOkb76_+9{G&!i;G{kVV|f^cVtSf8Y;1vsC8j3ijz_M!@=S2f~JAeAZ`#K z!Jn+Z;ZpfLge8;gs&q(7EsFnsKn1s*|L*ECuB^SgdmCdqfAD!wMLc+&f7VV|sFhT1 zfPGh{qdhGoZPv3$aLhNX1W2CA%7W9FZa3%?AV?zCIbB)2*vGGAbsrU{;QC5J3)DDm zAROf~(leT9!%-oHIcRw^%r zyupNq4%4s_|M~pio*k2)s@Y=;lE}fSpl0eXo3I57J`^hk5myDzXFP9;vS`{uhktl; z&YDu~C?$mtPA>Aog~@3o%eF!f4cSZnck#;RqspuafnbLLlC;C4z_N$a^Q(y#`|_4`B{1QMjM z-&cW99c3p6-6(fFGyMSJMRCy$`#Rn>aL1$E*KmaoieiRk5Z1L*8+4ZD2$opsBEour zoWV2GliTE~>);Cr$2MfkRbvN*l;F}Jwk@71ov$7~F=2tWJk+mMT5Zxxu@|keIy!(> z)g<@rXck_eBh*yG2s2yoeUkW;dzxuQ)+7JeAMdg^k3^`<_Sq3p4wd6RoKLo0%nxl- zh-@OCwBcSd#1}8ZgX)b>!xce*MSkxojmPe)pG!z72R^3G-yg1 zE{oX&v9MWie%_|zV5V^2KNNsv`QvZj#)5d?$AbSetoR*>>=a1)JLuGi@OR8#4G6!B z!?*(A;~Vf7=E-p(hJHLQ{1=J|PbntB*bcMw|EBKWi~Z{i=%m=|CviWS2x0g?nFf6i z@UKQJC;6I~K`ln^$$`s1L!LeypF}QVj$Ih!NwK;*zk~kVQK!Rti{KmdzsJCT7S_{; zz>^xYPU21;3ID%3vB?2HYWcTi|8$gnoJc=j&*^61q)zZj+>h4acPf8iPgl#6SWk)* zD*vpVzoY*A9pRs{z9A*x&yw}eUeM`+by8u%pRE6suiuILvE&JUb02cRXH1TqIsOm8{{h~exE=ri literal 0 HcmV?d00001 From 0edad93526ddf34876aca739cb7d8d584a905196 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:13:36 -0700 Subject: [PATCH 03/10] merge mod, quotient #37 --- .../src/expressions/parser/static_analysis.rs | 4 ++ base/src/functions/mathematical.rs | 36 ++++++++++++ base/src/functions/mod.rs | 10 ++++ base/src/test/mod.rs | 1 + base/src/test/test_fn_mod.rs | 55 +++++++++++++++++++ docs/src/functions/math-and-trigonometry.md | 4 +- .../functions/math_and_trigonometry/mod.md | 3 +- .../math_and_trigonometry/quotient.md | 3 +- 8 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 base/src/test/test_fn_mod.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 4d1df1f7..0ea7d896 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -624,6 +624,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Rounddown => args_signature_scalars(arg_count, 2, 0), Function::Roundup => args_signature_scalars(arg_count, 2, 0), + Function::Mod => args_signature_scalars(arg_count, 2, 0), + Function::Quotient => args_signature_scalars(arg_count, 2, 0), Function::Sin => args_signature_scalars(arg_count, 1, 0), Function::Sinh => args_signature_scalars(arg_count, 1, 0), Function::Sqrt => args_signature_scalars(arg_count, 1, 0), @@ -829,6 +831,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), + Function::Mod => scalar_arguments(args), + Function::Quotient => scalar_arguments(args), Function::Ln => scalar_arguments(args), Function::Log => scalar_arguments(args), Function::Log10 => scalar_arguments(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 6d769d55..62e6a3c2 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -584,6 +584,42 @@ impl Model { CalcResult::Number(result) } + pub(crate) fn fn_mod(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let divisor = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + if divisor == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); + } + CalcResult::Number(number - divisor * (number / divisor).floor()) + } + + pub(crate) fn fn_quotient(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let numerator = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let denominator = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(s) => return s, + }; + if denominator == 0.0 { + return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); + } + CalcResult::Number((numerator / denominator).trunc()) + } + pub(crate) fn fn_rand(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !args.is_empty() { return CalcResult::new_args_number_error(cell); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 6842cd15..93904ab4 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -60,9 +60,11 @@ pub enum Function { Int, Max, Min, + Mod, Pi, Power, Product, + Quotient, Rand, Randbetween, Ceiling, @@ -294,7 +296,9 @@ impl Function { Function::Power, Function::Max, Function::Min, + Function::Mod, Function::Product, + Function::Quotient, Function::Rand, Function::Randbetween, Function::Ceiling, @@ -555,7 +559,9 @@ impl Function { "MAX" => Some(Function::Max), "MIN" => Some(Function::Min), + "MOD" => Some(Function::Mod), "PRODUCT" => Some(Function::Product), + "QUOTIENT" => Some(Function::Quotient), "RAND" => Some(Function::Rand), "RANDBETWEEN" => Some(Function::Randbetween), "CEILING" => Some(Function::Ceiling), @@ -780,7 +786,9 @@ impl fmt::Display for Function { Function::Power => write!(f, "POWER"), Function::Max => write!(f, "MAX"), Function::Min => write!(f, "MIN"), + Function::Mod => write!(f, "MOD"), Function::Product => write!(f, "PRODUCT"), + Function::Quotient => write!(f, "QUOTIENT"), Function::Rand => write!(f, "RAND"), Function::Randbetween => write!(f, "RANDBETWEEN"), Function::Ceiling => write!(f, "CEILING"), @@ -1020,7 +1028,9 @@ impl Model { Function::Max => self.fn_max(args, cell), Function::Min => self.fn_min(args, cell), + Function::Mod => self.fn_mod(args, cell), Function::Product => self.fn_product(args, cell), + Function::Quotient => self.fn_quotient(args, cell), Function::Rand => self.fn_rand(args, cell), Function::Randbetween => self.fn_randbetween(args, cell), Function::Ceiling => self.fn_ceiling(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index fe019e6f..30b0f47f 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -21,6 +21,7 @@ mod test_fn_formulatext; mod test_fn_if; mod test_fn_maxifs; mod test_fn_minifs; +mod test_fn_mod; mod test_fn_or_xor; mod test_fn_product; mod test_fn_rept; diff --git a/base/src/test/test_fn_mod.rs b/base/src/test/test_fn_mod.rs new file mode 100644 index 00000000..9f804850 --- /dev/null +++ b/base/src/test/test_fn_mod.rs @@ -0,0 +1,55 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn mod_function() { + let mut model = new_empty_model(); + model._set("A1", "=MOD(9,4)"); + model._set("A2", "=MOD(-3,2)"); + model._set("A3", "=MOD(3,-2)"); + model._set("A4", "=MOD(3,0)"); + model._set("A5", "=MOD(1)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1"); + assert_eq!(model._get_text("A2"), *"1"); + assert_eq!(model._get_text("A3"), *"-1"); + assert_eq!(model._get_text("A4"), *"#DIV/0!"); + assert_eq!(model._get_text("A5"), *"#ERROR!"); +} + +#[test] +fn quotient_function() { + let mut model = new_empty_model(); + model._set("A1", "=QUOTIENT(5,2)"); + model._set("A2", "=QUOTIENT(5,-2)"); + model._set("A3", "=QUOTIENT(5,0)"); + model._set("A4", "=QUOTIENT(5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"-2"); // Fixed: should truncate toward zero, not floor + assert_eq!(model._get_text("A3"), *"#DIV/0!"); + assert_eq!(model._get_text("A4"), *"#ERROR!"); +} + +#[test] +fn quotient_function_truncate_toward_zero() { + let mut model = new_empty_model(); + model._set("A1", "=QUOTIENT(5,-2)"); // positive, negative truncation (original bug case) + model._set("A2", "=QUOTIENT(7,3)"); // positive, positive truncation + model._set("A3", "=QUOTIENT(-7,3)"); // negative, positive truncation + model._set("A4", "=QUOTIENT(-7,-3)"); // negative, negative truncation + model._set("A5", "=QUOTIENT(6,3)"); // exact division + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"-2"); // The key fix + assert_eq!(model._get_text("A2"), *"2"); + assert_eq!(model._get_text("A3"), *"-2"); + assert_eq!(model._get_text("A4"), *"2"); + assert_eq!(model._get_text("A5"), *"2"); +} diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index b17add03..a614ff45 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -55,7 +55,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MDETERM | | – | | MINVERSE | | – | | MMULT | | – | -| MOD | | – | +| MOD | | – | | MROUND | | [MROUND](math_and_trigonometry/mround) | | MULTINOMIAL | | – | | MUNIT | | – | @@ -63,7 +63,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PI | | – | | POWER | | – | | PRODUCT | | – | -| QUOTIENT | | – | +| QUOTIENT | | – | | RADIANS | | – | | RAND | | – | | RANDARRAY | | – | diff --git a/docs/src/functions/math_and_trigonometry/mod.md b/docs/src/functions/math_and_trigonometry/mod.md index 9f4eeccc..1025a2f4 100644 --- a/docs/src/functions/math_and_trigonometry/mod.md +++ b/docs/src/functions/math_and_trigonometry/mod.md @@ -7,6 +7,5 @@ lang: en-US # MOD ::: 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/math_and_trigonometry/quotient.md b/docs/src/functions/math_and_trigonometry/quotient.md index d0baef91..fbfe6edf 100644 --- a/docs/src/functions/math_and_trigonometry/quotient.md +++ b/docs/src/functions/math_and_trigonometry/quotient.md @@ -7,6 +7,5 @@ lang: en-US # QUOTIENT ::: 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 b2638b5f10e0ed341f49d3368a5658cf84d33268 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:15:08 -0700 Subject: [PATCH 04/10] merge gcd, lcm #39 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mathematical.rs | 222 ++++++++++++++++++ base/src/functions/mod.rs | 12 +- base/src/test/mod.rs | 2 + base/src/test/test_gcd.rs | 75 ++++++ base/src/test/test_lcm.rs | 82 +++++++ 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 base/src/test/test_gcd.rs create mode 100644 base/src/test/test_lcm.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 0ea7d896..bdb2f795 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -610,6 +610,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_row(arg_count), Function::Columns => args_signature_one_vector(arg_count), Function::Ln => args_signature_scalars(arg_count, 1, 0), + Function::Gcd => vec![Signature::Vector; arg_count], + Function::Lcm => vec![Signature::Vector; arg_count], Function::Log => args_signature_scalars(arg_count, 1, 1), Function::Log10 => args_signature_scalars(arg_count, 1, 0), Function::Cos => args_signature_scalars(arg_count, 1, 0), @@ -834,6 +836,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Mod => scalar_arguments(args), Function::Quotient => scalar_arguments(args), Function::Ln => scalar_arguments(args), + Function::Gcd => StaticResult::Scalar, + Function::Lcm => StaticResult::Scalar, Function::Log => scalar_arguments(args), Function::Log10 => scalar_arguments(args), Function::Sin => scalar_arguments(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 62e6a3c2..e9112759 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -620,6 +620,228 @@ impl Model { CalcResult::Number((numerator / denominator).trunc()) } + pub(crate) fn fn_gcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a + } + + let mut result: Option = None; + let mut update = |value: f64| -> Result<(), CalcResult> { + if value < 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "numbers must be positive".to_string(), + )); + } + if !value.is_finite() { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value must be finite".to_string(), + )); + } + let truncated = value.trunc(); + if truncated > u128::MAX as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value too large".to_string(), + )); + } + let v = truncated as u128; + result = Some(match result { + None => v, + Some(r) => gcd(r, v), + }); + Ok(()) + }; + + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(v) => { + if let Err(e) = update(v) { + return e; + } + } + 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(v) => { + if let Err(e) = update(v) { + return e; + } + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + } + } + } + CalcResult::Array(arr) => { + for row in arr { + for value in row { + match value { + ArrayNode::Number(v) => { + if let Err(e) = update(v) { + return e; + } + } + ArrayNode::Error(err) => { + return CalcResult::Error { + error: err, + origin: cell, + message: "Error in array".to_string(), + } + } + _ => {} + } + } + } + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + } + + CalcResult::Number(result.unwrap_or(0) as f64) + } + + pub(crate) fn fn_lcm(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a + } + + let mut result: Option = None; + let mut update = |value: f64| -> Result<(), CalcResult> { + if value < 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "numbers must be positive".to_string(), + )); + } + if !value.is_finite() { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value must be finite".to_string(), + )); + } + let truncated = value.trunc(); + if truncated > u128::MAX as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "value too large".to_string(), + )); + } + let v = truncated as u128; + result = Some(match result { + None => v, + Some(r) => { + if r == 0 || v == 0 { + 0 + } else { + r / gcd(r, v) * v + } + } + }); + Ok(()) + }; + + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(v) => { + if let Err(e) = update(v) { + return e; + } + } + 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(v) => { + if let Err(e) = update(v) { + return e; + } + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + } + } + } + CalcResult::Array(arr) => { + for row in arr { + for value in row { + match value { + ArrayNode::Number(v) => { + if let Err(e) = update(v) { + return e; + } + } + ArrayNode::Error(err) => { + return CalcResult::Error { + error: err, + origin: cell, + message: "Error in array".to_string(), + } + } + _ => {} + } + } + } + } + error @ CalcResult::Error { .. } => return error, + _ => {} + } + } + + CalcResult::Number(result.unwrap_or(0) as f64) + } + pub(crate) fn fn_rand(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !args.is_empty() { return CalcResult::new_args_number_error(cell); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 93904ab4..54512d56 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -58,6 +58,8 @@ pub enum Function { Log10, Ln, Int, + Gcd, + Lcm, Max, Min, Mod, @@ -259,7 +261,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -288,6 +290,8 @@ impl Function { Function::Pi, Function::Ln, Function::Int, + Function::Gcd, + Function::Lcm, Function::Log, Function::Log10, Function::Sqrt, @@ -554,6 +558,8 @@ impl Function { "LN" => Some(Function::Ln), "INT" => Some(Function::Int), + "GCD" => Some(Function::Gcd), + "LCM" => Some(Function::Lcm), "LOG" => Some(Function::Log), "LOG10" => Some(Function::Log10), @@ -766,6 +772,8 @@ impl fmt::Display for Function { Function::Log10 => write!(f, "LOG10"), Function::Ln => write!(f, "LN"), Function::Int => write!(f, "INT"), + Function::Gcd => write!(f, "GCD"), + Function::Lcm => write!(f, "LCM"), Function::Sin => write!(f, "SIN"), Function::Cos => write!(f, "COS"), Function::Tan => write!(f, "TAN"), @@ -1002,6 +1010,8 @@ impl Model { Function::Log10 => self.fn_log10(args, cell), Function::Ln => self.fn_ln(args, cell), Function::Int => self.fn_int(args, cell), + Function::Gcd => self.fn_gcd(args, cell), + Function::Lcm => self.fn_lcm(args, cell), Function::Sin => self.fn_sin(args, cell), Function::Cos => self.fn_cos(args, cell), Function::Tan => self.fn_tan(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 30b0f47f..f674e0cb 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -59,11 +59,13 @@ mod test_extend; mod test_fn_fv; mod test_fn_type; mod test_frozen_rows_and_columns; +mod test_gcd; mod test_geomean; mod test_get_cell_content; mod test_implicit_intersection; mod test_int; mod test_issue_155; +mod test_lcm; mod test_ln; mod test_log; mod test_log10; diff --git a/base/src/test/test_gcd.rs b/base/src/test/test_gcd.rs new file mode 100644 index 00000000..af1367d8 --- /dev/null +++ b/base/src/test/test_gcd.rs @@ -0,0 +1,75 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_gcd_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=GCD()"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_gcd_basic() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(12)"); + model._set("A2", "=GCD(60,36)"); + model._set("A3", "=GCD(15,25,35)"); + model._set("A4", "=GCD(12.7,8.3)"); // Decimal truncation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"12"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"4"); +} + +#[test] +fn test_fn_gcd_zeros_and_edge_cases() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(0)"); + model._set("A2", "=GCD(0,12)"); + model._set("A3", "=GCD(12,0)"); + model._set("A4", "=GCD(1,2,3,4,5)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"12"); + assert_eq!(model._get_text("A4"), *"1"); +} + +#[test] +fn test_fn_gcd_error_cases() { + let mut model = new_empty_model(); + model._set("A1", "=GCD(-5)"); + model._set("A2", "=GCD(12,-8)"); + model._set("B1", "=1/0"); // Infinity + model._set("A3", "=GCD(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); +} + +#[test] +fn test_fn_gcd_ranges() { + let mut model = new_empty_model(); + // Range with numbers + model._set("B1", "12"); + model._set("B2", "18"); + model._set("B3", "24"); + model._set("A1", "=GCD(B1:B3)"); + + // Range with mixed data (text ignored) + model._set("C1", "12"); + model._set("C2", "text"); + model._set("C3", "6"); + model._set("A2", "=GCD(C1:C3)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"6"); + assert_eq!(model._get_text("A2"), *"6"); +} diff --git a/base/src/test/test_lcm.rs b/base/src/test/test_lcm.rs new file mode 100644 index 00000000..ff1bc12f --- /dev/null +++ b/base/src/test/test_lcm.rs @@ -0,0 +1,82 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_lcm_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=LCM()"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#ERROR!"); +} + +#[test] +fn test_fn_lcm_basic() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(12)"); + model._set("A2", "=LCM(25,40)"); + model._set("A3", "=LCM(4,6,8)"); + model._set("A4", "=LCM(4.7,6.3)"); // Decimal truncation + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"12"); + assert_eq!(model._get_text("A2"), *"200"); + assert_eq!(model._get_text("A3"), *"24"); + assert_eq!(model._get_text("A4"), *"12"); +} + +#[test] +fn test_fn_lcm_zeros_and_edge_cases() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(0)"); + model._set("A2", "=LCM(0,12)"); + model._set("A3", "=LCM(12,0)"); + model._set("A4", "=LCM(1,2,3,4,5)"); + model.evaluate(); + + // LCM with any zero = 0 + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"0"); + assert_eq!(model._get_text("A3"), *"0"); + assert_eq!(model._get_text("A4"), *"60"); +} + +#[test] +fn test_fn_lcm_error_cases() { + let mut model = new_empty_model(); + model._set("A1", "=LCM(-5)"); + model._set("A2", "=LCM(12,-8)"); + model._set("B1", "=1/0"); // Infinity + model._set("A3", "=LCM(B1)"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#NUM!"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#DIV/0!"); +} + +#[test] +fn test_fn_lcm_ranges() { + let mut model = new_empty_model(); + // Range with numbers + model._set("B1", "4"); + model._set("B2", "6"); + model._set("B3", "8"); + model._set("A1", "=LCM(B1:B3)"); + + // Range with mixed data (text ignored) + model._set("C1", "4"); + model._set("C2", "text"); + model._set("C3", "6"); + model._set("A2", "=LCM(C1:C3)"); + + // Zero in range + model._set("D1", "4"); + model._set("D2", "0"); + model._set("A3", "=LCM(D1:D2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"24"); + assert_eq!(model._get_text("A2"), *"12"); + assert_eq!(model._get_text("A3"), *"0"); +} From fcfe6d43b3f8dec56e1542f0b2d78b1d32bb69c7 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:16:22 -0700 Subject: [PATCH 05/10] merge degrees, radians, pi #47 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mathematical.rs | 2 + base/src/functions/mod.rs | 10 ++ base/src/test/mod.rs | 1 + base/src/test/test_degrees_radians.rs | 94 +++++++++++++++++++ docs/src/functions/math-and-trigonometry.md | 6 +- .../math_and_trigonometry/degrees.md | 3 +- .../math_and_trigonometry/radians.md | 3 +- 8 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 base/src/test/test_degrees_radians.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index bdb2f795..fd6af3e2 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -616,6 +616,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Cos => args_signature_scalars(arg_count, 1, 0), Function::Cosh => args_signature_scalars(arg_count, 1, 0), + Function::Degrees => args_signature_scalars(arg_count, 1, 0), Function::Max => vec![Signature::Vector; arg_count], Function::Min => vec![Signature::Vector; arg_count], Function::Pi => args_signature_no_args(arg_count), @@ -623,6 +624,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Ceiling => args_signature_scalars(arg_count, 2, 0), Function::Floor => args_signature_scalars(arg_count, 2, 0), + Function::Radians => args_signature_scalars(arg_count, 1, 0), Function::Round => args_signature_scalars(arg_count, 2, 0), Function::Rounddown => args_signature_scalars(arg_count, 2, 0), Function::Roundup => args_signature_scalars(arg_count, 2, 0), @@ -823,6 +825,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Columns => not_implemented(args), Function::Cos => scalar_arguments(args), Function::Cosh => scalar_arguments(args), + Function::Degrees => scalar_arguments(args), Function::Max => StaticResult::Scalar, Function::Min => StaticResult::Scalar, Function::Pi => StaticResult::Scalar, @@ -830,6 +833,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Product => not_implemented(args), Function::Ceiling => scalar_arguments(args), Function::Floor => scalar_arguments(args), + Function::Radians => scalar_arguments(args), Function::Round => scalar_arguments(args), Function::Rounddown => scalar_arguments(args), Function::Roundup => scalar_arguments(args), diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index e9112759..be4e8f49 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -461,6 +461,8 @@ impl Model { single_number_fn!(fn_asinh, |f| Ok(f64::asinh(f))); single_number_fn!(fn_acosh, |f| Ok(f64::acosh(f))); single_number_fn!(fn_atanh, |f| Ok(f64::atanh(f))); + single_number_fn!(fn_degrees, |f: f64| Ok(f.to_degrees())); + single_number_fn!(fn_radians, |f: f64| Ok(f.to_radians())); single_number_fn!(fn_abs, |f| Ok(f64::abs(f))); single_number_fn!(fn_sqrt, |f| if f < 0.0 { Err(Error::NUM) diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 54512d56..057c7d59 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -54,6 +54,7 @@ pub enum Function { Columns, Cos, Cosh, + Degrees, Log, Log10, Ln, @@ -67,6 +68,7 @@ pub enum Function { Power, Product, Quotient, + Radians, Rand, Randbetween, Ceiling, @@ -282,6 +284,7 @@ impl Function { Function::Atan, Function::Sinh, Function::Cosh, + Function::Degrees, Function::Tanh, Function::Asinh, Function::Acosh, @@ -303,6 +306,7 @@ impl Function { Function::Mod, Function::Product, Function::Quotient, + Function::Radians, Function::Rand, Function::Randbetween, Function::Ceiling, @@ -543,6 +547,7 @@ impl Function { "SINH" => Some(Function::Sinh), "COSH" => Some(Function::Cosh), + "DEGREES" => Some(Function::Degrees), "TANH" => Some(Function::Tanh), "ASINH" => Some(Function::Asinh), @@ -568,6 +573,7 @@ impl Function { "MOD" => Some(Function::Mod), "PRODUCT" => Some(Function::Product), "QUOTIENT" => Some(Function::Quotient), + "RADIANS" => Some(Function::Radians), "RAND" => Some(Function::Rand), "RANDBETWEEN" => Some(Function::Randbetween), "CEILING" => Some(Function::Ceiling), @@ -782,6 +788,7 @@ impl fmt::Display for Function { Function::Atan => write!(f, "ATAN"), Function::Sinh => write!(f, "SINH"), Function::Cosh => write!(f, "COSH"), + Function::Degrees => write!(f, "DEGREES"), Function::Tanh => write!(f, "TANH"), Function::Asinh => write!(f, "ASINH"), Function::Acosh => write!(f, "ACOSH"), @@ -797,6 +804,7 @@ impl fmt::Display for Function { Function::Mod => write!(f, "MOD"), Function::Product => write!(f, "PRODUCT"), Function::Quotient => write!(f, "QUOTIENT"), + Function::Radians => write!(f, "RADIANS"), Function::Rand => write!(f, "RAND"), Function::Randbetween => write!(f, "RANDBETWEEN"), Function::Ceiling => write!(f, "CEILING"), @@ -1022,6 +1030,7 @@ impl Model { Function::Sinh => self.fn_sinh(args, cell), Function::Cosh => self.fn_cosh(args, cell), + Function::Degrees => self.fn_degrees(args, cell), Function::Tanh => self.fn_tanh(args, cell), Function::Asinh => self.fn_asinh(args, cell), @@ -1041,6 +1050,7 @@ impl Model { Function::Mod => self.fn_mod(args, cell), Function::Product => self.fn_product(args, cell), Function::Quotient => self.fn_quotient(args, cell), + Function::Radians => self.fn_radians(args, cell), Function::Rand => self.fn_rand(args, cell), Function::Randbetween => self.fn_randbetween(args, cell), Function::Ceiling => self.fn_ceiling(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index f674e0cb..49949e67 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -54,6 +54,7 @@ mod test_fn_offset; mod test_number_format; mod test_arrays; +mod test_degrees_radians; mod test_escape_quotes; mod test_extend; mod test_fn_fv; diff --git a/base/src/test/test_degrees_radians.rs b/base/src/test/test_degrees_radians.rs new file mode 100644 index 00000000..021d303f --- /dev/null +++ b/base/src/test/test_degrees_radians.rs @@ -0,0 +1,94 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn degrees_and_radians_arguments() { + let mut model = new_empty_model(); + model._set("A1", "=DEGREES()"); + model._set("A2", "=DEGREES(PI())"); + model._set("A3", "=DEGREES(1,2)"); + + model._set("B1", "=RADIANS()"); + model._set("B2", "=RADIANS(180)"); + model._set("B3", "=RADIANS(1,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"180"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"3.141592654"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn degrees_and_radians_typical_angles() { + let mut model = new_empty_model(); + + // RADIANS for common degree values + model._set("A1", "=RADIANS(90)"); + model._set("A2", "=RADIANS(270)"); + model._set("A3", "=RADIANS(360)"); + + // DEGREES for common radian values + model._set("B1", "=DEGREES(PI()/2)"); + model._set("B2", "=DEGREES(3*PI()/2)"); + model._set("B3", "=DEGREES(2*PI())"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"1.570796327"); + assert_eq!(model._get_text("A2"), *"4.71238898"); + assert_eq!(model._get_text("A3"), *"6.283185307"); + + assert_eq!(model._get_text("B1"), *"90"); + assert_eq!(model._get_text("B2"), *"270"); + assert_eq!(model._get_text("B3"), *"360"); +} + +#[test] +fn degrees_radians_round_trip_precision() { + let mut model = new_empty_model(); + + model._set("C1", "=DEGREES(RADIANS(123.456))"); + model._set("C2", "=RADIANS(DEGREES(PI()/4))"); + + model.evaluate(); + + // Round-trip check within general-format precision (string equality is enough) + assert_eq!(model._get_text("C1"), *"123.456"); + assert_eq!(model._get_text("C2"), *"0.785398163"); +} + +#[test] +fn test_fn_pi_value() { + let mut model = new_empty_model(); + model._set("D1", "=PI()"); + model.evaluate(); + + assert_eq!(model._get_text("D1"), *"3.141592654"); +} + +#[test] +fn degrees_radians_negative_and_zero() { + let mut model = new_empty_model(); + + // Zero angle + model._set("A1", "=RADIANS(0)"); + model._set("B1", "=DEGREES(0)"); + + // Negative angles + model._set("A2", "=RADIANS(-45)"); // -pi/4 + model._set("B2", "=DEGREES(-PI()/2)"); // -90 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("B1"), *"0"); + + assert_eq!(model._get_text("A2"), *"-0.785398163"); + assert_eq!(model._get_text("B2"), *"-90"); +} diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index a614ff45..c43f084f 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -36,7 +36,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | CSC | | – | | CSCH | | – | | DECIMAL | | – | -| DEGREES | | – | +| DEGREES | | [DEGREES](math_and_trigonometry/degrees) | | EVEN | | – | | EXP | | – | | FACT | | – | @@ -60,11 +60,11 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MULTINOMIAL | | – | | MUNIT | | – | | ODD | | – | -| PI | | – | +| PI | | [PI](math_and_trigonometry/pi) | | POWER | | – | | PRODUCT | | – | | QUOTIENT | | – | -| RADIANS | | – | +| RADIANS | | [RADIANS](math_and_trigonometry/radians) | | RAND | | – | | RANDARRAY | | – | | RANDBETWEEN | | – | diff --git a/docs/src/functions/math_and_trigonometry/degrees.md b/docs/src/functions/math_and_trigonometry/degrees.md index a82bcad5..d3a8dc22 100644 --- a/docs/src/functions/math_and_trigonometry/degrees.md +++ b/docs/src/functions/math_and_trigonometry/degrees.md @@ -7,6 +7,5 @@ lang: en-US # DEGREES ::: 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/math_and_trigonometry/radians.md b/docs/src/functions/math_and_trigonometry/radians.md index 17964557..31abf742 100644 --- a/docs/src/functions/math_and_trigonometry/radians.md +++ b/docs/src/functions/math_and_trigonometry/radians.md @@ -7,6 +7,5 @@ lang: en-US # RADIANS ::: 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 46fc93c35bdfe3e362b8001961284a1efa1e4ef8 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 20:20:20 -0700 Subject: [PATCH 06/10] fix build --- base/src/functions/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 057c7d59..39f215d6 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -263,7 +263,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, From e325830953a9727b1bd7eb341169d7c9627ba0bf Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 22:38:32 -0700 Subject: [PATCH 07/10] fix docs --- docs/src/functions/math-and-trigonometry.md | 12 ++++++------ docs/src/functions/math_and_trigonometry/gcd.md | 3 +-- docs/src/functions/math_and_trigonometry/lcm.md | 3 +-- .../functions/math_and_trigonometry/sumproduct.md | 3 +-- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index c43f084f..5ee3c083 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -44,10 +44,10 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | FLOOR | | [FLOOR](math_and_trigonometry/floor) | | FLOOR.MATH | | – | | FLOOR.PRECISE | | – | -| GCD | | – | +| GCD | | [GCD](math_and_trigonometry/gcd) | | INT | | [INT](math_and_trigonometry/int) | | ISO.CEILING | | – | -| LCM | | – | +| LCM | | [LCM](math_and_trigonometry/lcm) | | LET | | – | | LN | | – | | LOG | | – | @@ -55,7 +55,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MDETERM | | – | | MINVERSE | | – | | MMULT | | – | -| MOD | | – | +| MOD | | [MOD](math_and_trigonometry/mod) | | MROUND | | [MROUND](math_and_trigonometry/mround) | | MULTINOMIAL | | – | | MUNIT | | – | @@ -63,7 +63,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PI | | [PI](math_and_trigonometry/pi) | | POWER | | – | | PRODUCT | | – | -| QUOTIENT | | – | +| QUOTIENT | | [QUOTIENT](math_and_trigonometry/quotient) | | RADIANS | | [RADIANS](math_and_trigonometry/radians) | | RAND | | – | | RANDARRAY | | – | @@ -84,8 +84,8 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | SUBTOTAL | | – | | SUM | | – | | SUMIF | | – | -| SUMIFS | | – | -| SUMPRODUCT | | – | +| SUMIFS | | [SUMIFS](math_and_trigonometry/sumifs) | +| SUMPRODUCT | | [SUMPRODUCT](math_and_trigonometry/sumproduct) | | SUMSQ | | – | | SUMX2MY2 | | – | | SUMX2PY2 | | – | diff --git a/docs/src/functions/math_and_trigonometry/gcd.md b/docs/src/functions/math_and_trigonometry/gcd.md index f3fee3ad..5bd08b98 100644 --- a/docs/src/functions/math_and_trigonometry/gcd.md +++ b/docs/src/functions/math_and_trigonometry/gcd.md @@ -7,6 +7,5 @@ lang: en-US # GCD ::: 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/math_and_trigonometry/lcm.md b/docs/src/functions/math_and_trigonometry/lcm.md index dace2183..50918cfb 100644 --- a/docs/src/functions/math_and_trigonometry/lcm.md +++ b/docs/src/functions/math_and_trigonometry/lcm.md @@ -7,6 +7,5 @@ lang: en-US # LCM ::: 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/math_and_trigonometry/sumproduct.md b/docs/src/functions/math_and_trigonometry/sumproduct.md index 21866e9d..61937dd2 100644 --- a/docs/src/functions/math_and_trigonometry/sumproduct.md +++ b/docs/src/functions/math_and_trigonometry/sumproduct.md @@ -7,6 +7,5 @@ lang: en-US # SUMPRODUCT ::: 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 099b97226af6f33ec86915a8b08c4d68e5acc319 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 00:07:57 -0700 Subject: [PATCH 08/10] de-dupe --- base/src/functions/mathematical.rs | 583 ++++++++++++----------------- 1 file changed, 236 insertions(+), 347 deletions(-) diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index be4e8f49..f75e9447 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -8,6 +8,17 @@ use crate::{ }; use std::f64::consts::PI; +/// Shared GCD (Greatest Common Divisor) implementation +/// Used by both GCD and LCM functions +fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a +} + /// Specifies which rounding behaviour to apply when calling `round_to_multiple`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RoundKind { @@ -34,152 +45,179 @@ pub fn random() -> f64 { Math::random() } +/// Utility struct to hold optimized range bounds +struct RangeBounds { + row_start: i32, + row_end: i32, + col_start: i32, + col_end: i32, +} + impl Model { - pub(crate) fn fn_min(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let mut result = f64::NAN; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result = value.min(result), - 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) => { - result = value.min(result); - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - } - } - } + /// Resolves worksheet bounds by replacing LAST_ROW/LAST_COLUMN with actual sheet dimensions + /// Provides significant performance improvement for large range operations + fn resolve_worksheet_bounds( + &mut self, + left: CellReferenceIndex, + right: CellReferenceIndex, + cell: CellReferenceIndex, + ) -> Result { + let row_start = left.row; + let mut row_end = right.row; + let col_start = left.column; + let mut col_end = right.column; + + if row_start == 1 && row_end == LAST_ROW { + row_end = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + }; + } + if col_start == 1 && col_end == LAST_COLUMN { + col_end = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return Err(CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + )); } }; } - if result.is_nan() || result.is_infinite() { - return CalcResult::Number(0.0); + + Ok(RangeBounds { + row_start, + row_end, + col_start, + col_end, + }) + } + + /// Extracts exactly two numbers from function arguments with validation + /// Used by ATAN2, MOD, QUOTIENT, POWER, etc. + fn extract_two_numbers( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> Result<(f64, f64), CalcResult> { + if args.len() != 2 { + return Err(CalcResult::new_args_number_error(cell)); } - CalcResult::Number(result) + let first = self.get_number(&args[0], cell)?; + let second = self.get_number(&args[1], cell)?; + Ok((first, second)) } - pub(crate) fn fn_max(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let mut result = f64::NAN; + /// Applies a closure to all numeric values in function arguments (ranges, arrays, numbers) + /// Returns early on errors. Used by aggregate functions like GCD, LCM. + fn process_numeric_args( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + processor: &mut F, + ) -> Result<(), CalcResult> + where + F: FnMut(f64) -> Result<(), CalcResult>, + { for arg in args { match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result = value.max(result), + CalcResult::Number(v) => { + processor(v)?; + } CalcResult::Range { left, right } => { if left.sheet != right.sheet { - return CalcResult::new_error( + return Err(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) { + 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) => { - result = value.max(result); - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + CalcResult::Number(v) => { + processor(v)?; } + error @ CalcResult::Error { .. } => return Err(error), + _ => {} } } } } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings + CalcResult::Array(arr) => { + for row in arr { + for value in row { + match value { + ArrayNode::Number(v) => { + processor(v)?; + } + ArrayNode::Error(err) => { + return Err(CalcResult::Error { + error: err, + origin: cell, + message: "Error in array".to_string(), + }); + } + _ => {} + } + } + } } - }; - } - if result.is_nan() || result.is_infinite() { - return CalcResult::Number(0.0); + error @ CalcResult::Error { .. } => return Err(error), + _ => {} + } } - CalcResult::Number(result) + Ok(()) } - pub(crate) fn fn_sum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.is_empty() { - return CalcResult::new_args_number_error(cell); - } - - let mut result = 0.0; + /// Applies a closure to all numeric values in ranges with bounds optimization + /// Used by functions like SUM, PRODUCT that benefit from the optimization + fn process_numeric_args_with_range_bounds( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + processor: &mut F, + ) -> Result<(), CalcResult> + where + F: FnMut(f64) -> Result<(), CalcResult>, + { for arg in args { match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => result += value, + CalcResult::Number(value) => { + processor(value)?; + } CalcResult::Range { left, right } => { if left.sheet != right.sheet { - return CalcResult::new_error( + return Err(CalcResult::new_error( Error::VALUE, cell, "Ranges are in different sheets".to_string(), - ); - } - // TODO: We should do this for all functions that run through ranges - // Running cargo test for the ironcalc takes around .8 seconds with this speedup - // and ~ 3.5 seconds without it. Note that once properly in place sheet.dimension should be almost a noop - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; + )); } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { + let bounds = self.resolve_worksheet_bounds(left, right, cell)?; + + for row in bounds.row_start..=bounds.row_end { + for column in bounds.col_start..=bounds.col_end { match self.evaluate_cell(CellReferenceIndex { sheet: left.sheet, row, column, }) { CalcResult::Number(value) => { - result += value; + processor(value)?; } - error @ CalcResult::Error { .. } => return error, + error @ CalcResult::Error { .. } => return Err(error), _ => { // We ignore booleans and strings } @@ -192,14 +230,14 @@ impl Model { for value in row { match value { ArrayNode::Number(value) => { - result += value; + processor(value)?; } ArrayNode::Error(error) => { - return CalcResult::Error { + return Err(CalcResult::Error { error, origin: cell, message: "Error in array".to_string(), - } + }); } _ => { // We ignore booleans and strings @@ -208,88 +246,93 @@ impl Model { } } } - error @ CalcResult::Error { .. } => return error, + error @ CalcResult::Error { .. } => return Err(error), _ => { // We ignore booleans and strings } - }; + } + } + Ok(()) + } + + pub(crate) fn fn_min(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let mut result = f64::NAN; + let mut min_processor = |value: f64| -> Result<(), CalcResult> { + result = value.min(result); + Ok(()) + }; + + // Use the optimized utility function for range processing + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut min_processor) + { + return e; + } + + if result.is_nan() || result.is_infinite() { + return CalcResult::Number(0.0); } CalcResult::Number(result) } + pub(crate) fn fn_max(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let mut result = f64::NAN; + let mut max_processor = |value: f64| -> Result<(), CalcResult> { + result = value.max(result); + Ok(()) + }; + + // Use the optimized utility function for range processing + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut max_processor) + { + return e; + } + + if result.is_nan() || result.is_infinite() { + return CalcResult::Number(0.0); + } + CalcResult::Number(result) + } + + pub(crate) fn fn_sum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + let mut result = 0.0; + let mut sum_processor = |value: f64| -> Result<(), CalcResult> { + result += value; + Ok(()) + }; + + // Use the new utility function with optimization for range bounds + if let Err(e) = self.process_numeric_args_with_range_bounds(args, cell, &mut sum_processor) + { + return e; + } + + CalcResult::Number(result) + } + pub(crate) fn fn_product(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.is_empty() { return CalcResult::new_args_number_error(cell); } + let mut result = 1.0; let mut seen_value = false; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - seen_value = true; - result *= value; - } - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - ); - } - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - seen_value = true; - result *= value; - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - } - } - } - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - }; + let mut product_processor = |value: f64| -> Result<(), CalcResult> { + seen_value = true; + result *= value; + Ok(()) + }; + + // Use the new utility function with optimization for range bounds + if let Err(e) = + self.process_numeric_args_with_range_bounds(args, cell, &mut product_processor) + { + return e; } + if !seen_value { return CalcResult::Number(0.0); } @@ -483,16 +526,9 @@ impl Model { } pub(crate) fn fn_atan2(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let x = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let y = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (x, y) = match self.extract_two_numbers(args, cell) { + Ok((x, y)) => (x, y), + Err(e) => return e, }; if x == 0.0 && y == 0.0 { return CalcResult::Error { @@ -546,28 +582,21 @@ impl Model { } pub(crate) fn fn_power(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let x = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let y = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (base, exp) = match self.extract_two_numbers(args, cell) { + Ok((base, exp)) => (base, exp), + Err(e) => return e, }; - if x == 0.0 && y == 0.0 { + if base == 0.0 && exp == 0.0 { return CalcResult::Error { error: Error::NUM, origin: cell, message: "Arguments can't be both zero".to_string(), }; } - if y == 0.0 { + if exp == 0.0 { return CalcResult::Number(1.0); } - let result = x.powf(y); + let result = base.powf(exp); if result.is_infinite() { return CalcResult::Error { error: Error::DIV, @@ -587,16 +616,9 @@ impl Model { } pub(crate) fn fn_mod(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let number = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let divisor = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (number, divisor) = match self.extract_two_numbers(args, cell) { + Ok((num, div)) => (num, div), + Err(e) => return e, }; if divisor == 0.0 { return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); @@ -605,16 +627,9 @@ impl Model { } pub(crate) fn fn_quotient(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); - } - let numerator = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let denominator = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (numerator, denominator) = match self.extract_two_numbers(args, cell) { + Ok((num, den)) => (num, den), + Err(e) => return e, }; if denominator == 0.0 { return CalcResult::new_error(Error::DIV, cell, "Divide by 0".to_string()); @@ -627,15 +642,6 @@ impl Model { return CalcResult::new_args_number_error(cell); } - fn gcd(mut a: u128, mut b: u128) -> u128 { - while b != 0 { - let r = a % b; - a = b; - b = r; - } - a - } - let mut result: Option = None; let mut update = |value: f64| -> Result<(), CalcResult> { if value < 0.0 { @@ -668,63 +674,9 @@ impl Model { Ok(()) }; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(v) => { - if let Err(e) = update(v) { - return e; - } - } - 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(v) => { - if let Err(e) = update(v) { - return e; - } - } - error @ CalcResult::Error { .. } => return error, - _ => {} - } - } - } - } - CalcResult::Array(arr) => { - for row in arr { - for value in row { - match value { - ArrayNode::Number(v) => { - if let Err(e) = update(v) { - return e; - } - } - ArrayNode::Error(err) => { - return CalcResult::Error { - error: err, - origin: cell, - message: "Error in array".to_string(), - } - } - _ => {} - } - } - } - } - error @ CalcResult::Error { .. } => return error, - _ => {} - } + // Use the new utility function to process all numeric arguments + if let Err(e) = self.process_numeric_args(args, cell, &mut update) { + return e; } CalcResult::Number(result.unwrap_or(0) as f64) @@ -735,15 +687,6 @@ impl Model { return CalcResult::new_args_number_error(cell); } - fn gcd(mut a: u128, mut b: u128) -> u128 { - while b != 0 { - let r = a % b; - a = b; - b = r; - } - a - } - let mut result: Option = None; let mut update = |value: f64| -> Result<(), CalcResult> { if value < 0.0 { @@ -782,63 +725,9 @@ impl Model { Ok(()) }; - for arg in args { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(v) => { - if let Err(e) = update(v) { - return e; - } - } - 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(v) => { - if let Err(e) = update(v) { - return e; - } - } - error @ CalcResult::Error { .. } => return error, - _ => {} - } - } - } - } - CalcResult::Array(arr) => { - for row in arr { - for value in row { - match value { - ArrayNode::Number(v) => { - if let Err(e) = update(v) { - return e; - } - } - ArrayNode::Error(err) => { - return CalcResult::Error { - error: err, - origin: cell, - message: "Error in array".to_string(), - } - } - _ => {} - } - } - } - } - error @ CalcResult::Error { .. } => return error, - _ => {} - } + // Use the new utility function to process all numeric arguments + if let Err(e) = self.process_numeric_args(args, cell, &mut update) { + return e; } CalcResult::Number(result.unwrap_or(0) as f64) From f06137a7b4a0511adb362b17d376735b24c6a927 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 00:50:44 -0700 Subject: [PATCH 09/10] fix docs --- docs/src/functions/math-and-trigonometry.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/src/functions/math-and-trigonometry.md b/docs/src/functions/math-and-trigonometry.md index 5ee3c083..a3deb2b7 100644 --- a/docs/src/functions/math-and-trigonometry.md +++ b/docs/src/functions/math-and-trigonometry.md @@ -49,9 +49,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | ISO.CEILING | | – | | LCM | | [LCM](math_and_trigonometry/lcm) | | LET | | – | -| LN | | – | -| LOG | | – | -| LOG10 | | – | +| LN | | – | +| LOG | | – | +| LOG10 | | – | | MDETERM | | – | | MINVERSE | | – | | MMULT | | – | @@ -80,12 +80,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | SIN | | [SIN](math_and_trigonometry/sin) | | SINH | | – | | SQRT | | – | -| SQRTPI | | – | -| SUBTOTAL | | – | +| SQRTPI | | – | +| SUBTOTAL | | – | | SUM | | – | | SUMIF | | – | | SUMIFS | | [SUMIFS](math_and_trigonometry/sumifs) | -| SUMPRODUCT | | [SUMPRODUCT](math_and_trigonometry/sumproduct) | +| SUMPRODUCT | | – | | SUMSQ | | – | | SUMX2MY2 | | – | | SUMX2PY2 | | – | From ed9a86371431a65d6c415a5e095a063ab6d2b0e9 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 30 Jul 2025 12:01:37 -0700 Subject: [PATCH 10/10] mround edge cases --- base/src/functions/mathematical.rs | 24 ++++++++++----- base/src/test/test_mround.rs | 48 ++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index f75e9447..29a221a0 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -780,20 +780,30 @@ impl Model { if significance == 0.0 { return CalcResult::Number(0.0); } - if (number > 0.0 && significance < 0.0) || (number < 0.0 && significance > 0.0) { + // Special case: zero number always returns 0 regardless of significance sign + if number == 0.0 { + return CalcResult::Number(0.0); + } + // Excel requires number and significance to have the same sign for non-zero numbers + if number.signum() != significance.signum() { return CalcResult::Error { error: Error::NUM, origin: cell, message: "number and significance must have the same sign".to_string(), }; } - let abs_sign = significance.abs(); - let quotient = number / abs_sign; - let rounded = if quotient >= 0.0 { - (quotient + 0.5).floor() + + // MROUND rounds ties away from zero (unlike Rust's round() which uses banker's rounding) + let quotient = number / significance; + + // Add f64::EPSILON to handle floating-point precision at exact 0.5 boundaries + // e.g., when 0.15/0.1 gives 1.4999999999999998 instead of exactly 1.5 + let rounded_quotient = if quotient >= 0.0 { + (quotient + 0.5 + f64::EPSILON).floor() } else { - (quotient - 0.5).ceil() + (quotient - 0.5 - f64::EPSILON).ceil() }; - CalcResult::Number(rounded * abs_sign) + + CalcResult::Number(rounded_quotient * significance) } } diff --git a/base/src/test/test_mround.rs b/base/src/test/test_mround.rs index f5ca55ac..b4ad7493 100644 --- a/base/src/test/test_mround.rs +++ b/base/src/test/test_mround.rs @@ -19,10 +19,9 @@ fn mround_wrong_argument_count() { #[test] fn mround_basic_rounding() { let mut model = new_empty_model(); - // MROUND rounds to nearest multiple of significance - model._set("A1", "=MROUND(10, 3)"); // 9 (closest multiple of 3) - model._set("A2", "=MROUND(11, 3)"); // 12 (rounds up at midpoint) - model._set("A3", "=MROUND(1.3, 0.2)"); // 1.4 (decimal significance) + model._set("A1", "=MROUND(10, 3)"); // 9 + model._set("A2", "=MROUND(11, 3)"); // 12 + model._set("A3", "=MROUND(1.3, 0.2)"); // 1.4 model.evaluate(); @@ -34,10 +33,10 @@ fn mround_basic_rounding() { #[test] fn mround_sign_validation() { let mut model = new_empty_model(); - // Critical: number and significance must have same sign - model._set("A1", "=MROUND(10, -3)"); // positive number, negative significance - model._set("A2", "=MROUND(-10, 3)"); // negative number, positive significance - model._set("A3", "=MROUND(-10, -3)"); // both negative - valid + // Number and significance must have same sign + model._set("A1", "=MROUND(10, -3)"); // NUM error + model._set("A2", "=MROUND(-10, 3)"); // NUM error + model._set("A3", "=MROUND(-10, -3)"); // -9 model.evaluate(); @@ -49,14 +48,39 @@ fn mround_sign_validation() { #[test] fn mround_special_cases() { let mut model = new_empty_model(); - // Zero significance always returns 0 - model._set("A1", "=MROUND(10, 0)"); - model._set("A2", "=MROUND(0, 5)"); // zero rounds to zero - model._set("A3", "=MROUND(2.5, 5)"); // midpoint rounding (rounds up) + model._set("A1", "=MROUND(10, 0)"); // 0 + model._set("A2", "=MROUND(0, 5)"); // 0 + model._set("A3", "=MROUND(2.5, 5)"); // 5 + // Zero number with any significance sign + model._set("A4", "=MROUND(0, -1)"); // 0 + model._set("A5", "=MROUND(0, -5)"); // 0 model.evaluate(); assert_eq!(model._get_text("A1"), *"0"); assert_eq!(model._get_text("A2"), *"0"); assert_eq!(model._get_text("A3"), *"5"); + assert_eq!(model._get_text("A4"), *"0"); + assert_eq!(model._get_text("A5"), *"0"); +} + +#[test] +fn mround_precision_edge_cases() { + let mut model = new_empty_model(); + // Floating-point precision at midpoints + model._set("A1", "=MROUND(1.5, 1)"); // 2 + model._set("A2", "=MROUND(-1.5, -1)"); // -2 + model._set("A3", "=MROUND(2.5, 1)"); // 3 + model._set("A4", "=MROUND(-2.5, -1)"); // -3 + model._set("A5", "=MROUND(0.15, 0.1)"); // 0.2 + model._set("A6", "=MROUND(-0.15, -0.1)"); // -0.2 + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"-2"); + assert_eq!(model._get_text("A3"), *"3"); + assert_eq!(model._get_text("A4"), *"-3"); + assert_eq!(model._get_text("A5"), *"0.2"); + assert_eq!(model._get_text("A6"), *"-0.2"); }