From 27cd010dfb378bcc8f4bfd19047131f2d56616c4 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 13 Jul 2025 00:21:15 -0700 Subject: [PATCH] Add SLOPE and INTERCEPT functions --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mod.rs | 12 +- base/src/functions/statistical.rs | 240 ++++++++++++++++++ base/src/test/mod.rs | 1 + base/src/test/test_fn_slope.rs | 31 +++ 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 base/src/test/test_fn_slope.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 280ac2484..a31f00547 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -682,6 +682,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Maxifs => vec![Signature::Vector; arg_count], Function::Minifs => vec![Signature::Vector; arg_count], + Function::Slope => vec![Signature::Vector; arg_count], + Function::Intercept => vec![Signature::Vector; arg_count], Function::Date => args_signature_scalars(arg_count, 3, 0), Function::Day => args_signature_scalars(arg_count, 1, 0), Function::Edate => args_signature_scalars(arg_count, 2, 0), @@ -980,5 +982,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Slope => not_implemented(args), + Function::Intercept => not_implemented(args), } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 45da02525..0a371c1eb 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -142,6 +142,8 @@ pub enum Function { Maxifs, Minifs, Geomean, + Slope, + Intercept, // Date and time Date, @@ -250,7 +252,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -351,6 +353,8 @@ impl Function { Function::Maxifs, Function::Minifs, Function::Geomean, + Function::Slope, + Function::Intercept, Function::Year, Function::Day, Function::Month, @@ -615,6 +619,8 @@ impl Function { "MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs), "MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs), "GEOMEAN" => Some(Function::Geomean), + "SLOPE" => Some(Function::Slope), + "INTERCEPT" => Some(Function::Intercept), // Date and Time "YEAR" => Some(Function::Year), "DAY" => Some(Function::Day), @@ -823,6 +829,8 @@ impl fmt::Display for Function { Function::Maxifs => write!(f, "MAXIFS"), Function::Minifs => write!(f, "MINIFS"), Function::Geomean => write!(f, "GEOMEAN"), + Function::Slope => write!(f, "SLOPE"), + Function::Intercept => write!(f, "INTERCEPT"), Function::Year => write!(f, "YEAR"), Function::Day => write!(f, "DAY"), Function::Month => write!(f, "MONTH"), @@ -1060,6 +1068,8 @@ impl Model { Function::Maxifs => self.fn_maxifs(args, cell), Function::Minifs => self.fn_minifs(args, cell), Function::Geomean => self.fn_geomean(args, cell), + Function::Slope => self.fn_slope(args, cell), + Function::Intercept => self.fn_intercept(args, cell), // Date and Time Function::Year => self.fn_year(args, cell), Function::Day => self.fn_day(args, cell), diff --git a/base/src/functions/statistical.rs b/base/src/functions/statistical.rs index cdb936406..39829647d 100644 --- a/base/src/functions/statistical.rs +++ b/base/src/functions/statistical.rs @@ -730,4 +730,244 @@ impl Model { } CalcResult::Number(product.powf(1.0 / count)) } + + fn get_range_bounds( + &mut self, + left: CellReferenceIndex, + right: CellReferenceIndex, + cell: CellReferenceIndex, + ) -> Result<(u32, i32, i32, i32, i32), CalcResult> { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + let sheet = left.sheet; + 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 = self + .workbook + .worksheet(sheet) + .map_err(|_| { + CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{sheet}'"), + ) + })? + .dimension() + .max_row; + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = self + .workbook + .worksheet(sheet) + .map_err(|_| { + CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{sheet}'"), + ) + })? + .dimension() + .max_column; + } + Ok((sheet, row1, row2, column1, column2)) + } + + fn cell_number_or_none(&self, result: CalcResult) -> Result, CalcResult> { + match result { + CalcResult::Number(v) => Ok(Some(v)), + CalcResult::Error { .. } => Err(result), + _ => Ok(None), + } + } + + fn calcresult_to_number( + &self, + result: CalcResult, + cell: CellReferenceIndex, + ) -> Result { + match result { + CalcResult::Number(f) => Ok(f), + CalcResult::String(s) => s.parse::().map_err(|_| { + CalcResult::new_error(Error::VALUE, cell, "Expecting number".to_string()) + }), + CalcResult::Boolean(b) => Ok(if b { 1.0 } else { 0.0 }), + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(0.0), + error @ CalcResult::Error { .. } => Err(error), + CalcResult::Range { .. } | CalcResult::Array(_) => Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }), + } + } + + fn collect_pairs( + &mut self, + y_node: &Node, + x_node: &Node, + cell: CellReferenceIndex, + ) -> Result<(Vec, Vec), CalcResult> { + let y_res = self.evaluate_node_in_context(y_node, cell); + if y_res.is_error() { + return Err(y_res); + } + let x_res = self.evaluate_node_in_context(x_node, cell); + if x_res.is_error() { + return Err(x_res); + } + + let mut ys = Vec::new(); + let mut xs = Vec::new(); + + match (y_res, x_res) { + ( + CalcResult::Range { left: l_y, right: r_y }, + CalcResult::Range { left: l_x, right: r_x }, + ) => { + let (sheet_y, row1_y, row2_y, col1_y, col2_y) = + self.get_range_bounds(l_y, r_y, cell)?; + let (sheet_x, row1_x, row2_x, col1_x, col2_x) = + self.get_range_bounds(l_x, r_x, cell)?; + let rows_y = row2_y - row1_y + 1; + let cols_y = col2_y - col1_y + 1; + let rows_x = row2_x - row1_x + 1; + let cols_x = col2_x - col1_x + 1; + if rows_y != rows_x || cols_y != cols_x { + return Err(CalcResult::new_error( + Error::NA, + cell, + "Ranges must be the same size".to_string(), + )); + } + for i in 0..rows_y { + for j in 0..cols_y { + let y_val = self.evaluate_cell(CellReferenceIndex { + sheet: sheet_y, + row: row1_y + i, + column: col1_y + j, + }); + let x_val = self.evaluate_cell(CellReferenceIndex { + sheet: sheet_x, + row: row1_x + i, + column: col1_x + j, + }); + let y_num = self.cell_number_or_none(y_val)?; + let x_num = self.cell_number_or_none(x_val)?; + if let (Some(y), Some(x)) = (y_num, x_num) { + ys.push(y); + xs.push(x); + } + } + } + } + (CalcResult::Range { .. }, CalcResult::Number(_)) + | (CalcResult::Number(_), CalcResult::Range { .. }) => { + return Err(CalcResult::new_error( + Error::NA, + cell, + "Ranges must be the same size".to_string(), + )); + } + (CalcResult::Number(ny), CalcResult::Number(nx)) => { + ys.push(ny); + xs.push(nx); + } + (CalcResult::Number(ny), other) => { + let nx = match self.calcresult_to_number(other, cell) { + Ok(f) => f, + Err(e) => return Err(e), + }; + ys.push(ny); + xs.push(nx); + } + (other, CalcResult::Number(nx)) => { + let ny = match self.calcresult_to_number(other, cell) { + Ok(f) => f, + Err(e) => return Err(e), + }; + ys.push(ny); + xs.push(nx); + } + (other_y, other_x) => { + let ny = match self.calcresult_to_number(other_y, cell) { + Ok(f) => f, + Err(e) => return Err(e), + }; + let nx = match self.calcresult_to_number(other_x, cell) { + Ok(f) => f, + Err(e) => return Err(e), + }; + ys.push(ny); + xs.push(nx); + } + } + Ok((xs, ys)) + } + + pub(crate) fn fn_slope(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let (xs, ys) = match self.collect_pairs(&args[0], &args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let n = xs.len(); + if n < 2 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let n_f = n as f64; + let sum_x: f64 = xs.iter().sum(); + let sum_y: f64 = ys.iter().sum(); + let mean_x = sum_x / n_f; + let mean_y = sum_y / n_f; + let mut num = 0.0; + let mut den = 0.0; + for (x, y) in xs.iter().zip(ys.iter()) { + num += (x - mean_x) * (y - mean_y); + den += (x - mean_x).powi(2); + } + if den.abs() < f64::EPSILON { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + CalcResult::Number(num / den) + } + + pub(crate) fn fn_intercept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let (xs, ys) = match self.collect_pairs(&args[0], &args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let n = xs.len(); + if n < 2 { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let n_f = n as f64; + let sum_x: f64 = xs.iter().sum(); + let sum_y: f64 = ys.iter().sum(); + let mean_x = sum_x / n_f; + let mean_y = sum_y / n_f; + let mut num = 0.0; + let mut den = 0.0; + for (x, y) in xs.iter().zip(ys.iter()) { + num += (x - mean_x) * (y - mean_y); + den += (x - mean_x).powi(2); + } + if den.abs() < f64::EPSILON { + return CalcResult::new_error(Error::DIV, cell, "Division by Zero".to_string()); + } + let slope = num / den; + CalcResult::Number(mean_y - slope * mean_x) + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 8e1b4ebe1..7292686ad 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -58,6 +58,7 @@ mod test_fn_fv; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; +mod test_fn_slope; mod test_get_cell_content; mod test_implicit_intersection; mod test_issue_155; diff --git a/base/src/test/test_fn_slope.rs b/base/src/test/test_fn_slope.rs new file mode 100644 index 000000000..38011969b --- /dev/null +++ b/base/src/test/test_fn_slope.rs @@ -0,0 +1,31 @@ +#![allow(clippy::unwrap_used)] +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_slope_and_intercept() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "2"); + model._set("C2", "4"); + model._set("C3", "6"); + model._set("A1", "=SLOPE(B1:B3,C1:C3)"); + model._set("A2", "=INTERCEPT(B1:B3,C1:C3)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"0.5"); + assert_eq!(model._get_text("A2"), *"0"); +} + +#[test] +fn test_fn_slope_mismatch() { + let mut model = new_empty_model(); + model._set("B1", "1"); + model._set("B2", "2"); + model._set("B3", "3"); + model._set("C1", "2"); + model._set("C2", "4"); + model._set("A1", "=SLOPE(B1:B3,C1:C2)"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#N/A"); +}