Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions base/src/expressions/parser/static_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,9 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Formulatext => args_signature_scalars(arg_count, 1, 0),
Function::Unicode => args_signature_scalars(arg_count, 1, 0),
Function::Geomean => vec![Signature::Vector; arg_count],
Function::Median => vec![Signature::Vector; arg_count],
Function::StdevS => vec![Signature::Vector; arg_count],
Function::StdevP => vec![Signature::Vector; arg_count],
}
}

Expand Down Expand Up @@ -990,5 +993,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Eomonth => scalar_arguments(args),
Function::Formulatext => not_implemented(args),
Function::Geomean => not_implemented(args),
Function::Median => not_implemented(args),
Function::StdevS => not_implemented(args),
Function::StdevP => not_implemented(args),
}
}
17 changes: 16 additions & 1 deletion base/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ pub enum Function {
Maxifs,
Minifs,
Geomean,
Median,
StdevS,
StdevP,

// Date and time
Date,
Expand Down Expand Up @@ -253,7 +256,7 @@ pub enum Function {
}

impl Function {
pub fn into_iter() -> IntoIter<Function, 198> {
pub fn into_iter() -> IntoIter<Function, 201> {
[
Function::And,
Function::False,
Expand Down Expand Up @@ -357,6 +360,9 @@ impl Function {
Function::Maxifs,
Function::Minifs,
Function::Geomean,
Function::Median,
Function::StdevS,
Function::StdevP,
Function::Year,
Function::Day,
Function::Month,
Expand Down Expand Up @@ -625,6 +631,9 @@ impl Function {
"MAXIFS" | "_XLFN.MAXIFS" => Some(Function::Maxifs),
"MINIFS" | "_XLFN.MINIFS" => Some(Function::Minifs),
"GEOMEAN" => Some(Function::Geomean),
"MEDIAN" => Some(Function::Median),
"STDEV.S" => Some(Function::StdevS),
"STDEV.P" => Some(Function::StdevP),
// Date and Time
"YEAR" => Some(Function::Year),
"DAY" => Some(Function::Day),
Expand Down Expand Up @@ -836,6 +845,9 @@ impl fmt::Display for Function {
Function::Maxifs => write!(f, "MAXIFS"),
Function::Minifs => write!(f, "MINIFS"),
Function::Geomean => write!(f, "GEOMEAN"),
Function::Median => write!(f, "MEDIAN"),
Function::StdevS => write!(f, "STDEV.S"),
Function::StdevP => write!(f, "STDEV.P"),
Function::Year => write!(f, "YEAR"),
Function::Day => write!(f, "DAY"),
Function::Month => write!(f, "MONTH"),
Expand Down Expand Up @@ -1076,6 +1088,9 @@ impl Model {
Function::Maxifs => self.fn_maxifs(args, cell),
Function::Minifs => self.fn_minifs(args, cell),
Function::Geomean => self.fn_geomean(args, cell),
Function::Median => self.fn_median(args, cell),
Function::StdevS => self.fn_stdev_s(args, cell),
Function::StdevP => self.fn_stdev_p(args, cell),
// Date and Time
Function::Year => self.fn_year(args, cell),
Function::Day => self.fn_day(args, cell),
Expand Down
146 changes: 77 additions & 69 deletions base/src/functions/statistical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::{
model::Model,
};

use super::util::build_criteria;
use super::util::{build_criteria, collect_numeric_values};
use std::cmp::Ordering;

impl Model {
pub(crate) fn fn_average(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
Expand Down Expand Up @@ -654,80 +655,87 @@ impl Model {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut count = 0.0;
let mut product = 1.0;
for arg in args {
match self.evaluate_node_in_context(arg, cell) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
CalcResult::Boolean(b) => {
if let Node::ReferenceKind { .. } = arg {
} else {
product *= if b { 1.0 } else { 0.0 };
count += 1.0;
}
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
);
}
for row in left.row..(right.row + 1) {
for column in left.column..(right.column + 1) {
match self.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(value) => {
count += 1.0;
product *= value;
}
error @ CalcResult::Error { .. } => return error,
CalcResult::Range { .. } => {
return CalcResult::new_error(
Error::ERROR,
cell,
"Unexpected Range".to_string(),
);
}
_ => {}
}
}
}
}
error @ CalcResult::Error { .. } => return error,
CalcResult::String(s) => {
if let Node::ReferenceKind { .. } = arg {
// Do nothing
} else if let Ok(t) = s.parse::<f64>() {
product *= t;
count += 1.0;
} else {
return CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument cannot be cast into number".to_string(),
};
}
}
_ => {
// Ignore everything else
}
};
}
if count == 0.0 {
let values = match collect_numeric_values(self, args, cell) {
Ok(v) => v,
Err(err) => return err,
};

if values.is_empty() {
return CalcResult::Error {
error: Error::DIV,
origin: cell,
message: "Division by Zero".to_string(),
};
}

let product: f64 = values.iter().product();
let count = values.len() as f64;
CalcResult::Number(product.powf(1.0 / count))
}

pub(crate) fn fn_median(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let mut values = match collect_numeric_values(self, args, cell) {
Ok(v) => v,
Err(err) => return err,
};
if values.is_empty() {
return CalcResult::Number(0.0);
}
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let len = values.len();
if len % 2 == 1 {
CalcResult::Number(values[len / 2])
} else {
CalcResult::Number((values[len / 2 - 1] + values[len / 2]) / 2.0)
}
}

pub(crate) fn fn_stdev_s(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let values = match collect_numeric_values(self, args, cell) {
Ok(v) => v,
Err(err) => return err,
};
let n = values.len();
if n < 2 {
return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string());
}
let sum: f64 = values.iter().sum();
let mean = sum / n as f64;
let mut variance = 0.0;
for v in &values {
variance += (v - mean).powi(2);
}
variance /= n as f64 - 1.0;
CalcResult::Number(variance.sqrt())
}

pub(crate) fn fn_stdev_p(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.is_empty() {
return CalcResult::new_args_number_error(cell);
}
let values = match collect_numeric_values(self, args, cell) {
Ok(v) => v,
Err(err) => return err,
};
let n = values.len();
if n == 0 {
return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string());
}
let sum: f64 = values.iter().sum();
let mean = sum / n as f64;
let mut variance = 0.0;
for v in &values {
variance += (v - mean).powi(2);
}
variance /= n as f64;
CalcResult::Number(variance.sqrt())
}

// collect_numeric_values moved to functions::util
}
86 changes: 85 additions & 1 deletion base/src/functions/util.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
#[cfg(feature = "use_regex_lite")]
use regex_lite as regex;

use crate::{calc_result::CalcResult, expressions::token::is_english_error_string};
use crate::{
calc_result::CalcResult,
expressions::{
parser::Node,
token::{is_english_error_string, Error},
types::CellReferenceIndex,
},
model::Model,
};

/// This test for exact match (modulo case).
/// * strings are not cast into bools or numbers
Expand Down Expand Up @@ -398,3 +406,79 @@ pub(crate) fn build_criteria<'a>(value: &'a CalcResult) -> Box<dyn Fn(&CalcResul
CalcResult::EmptyCell | CalcResult::EmptyArg => Box::new(result_is_equal_to_empty),
}
}

/// Collects all numeric values from a function’s argument list.
///
/// Traverses each Node, evaluates it in context, and returns the numeric
/// scalars as `Ok(Vec<f64>)`. Propagates the first error encountered.
///
/// Behaviour rules (Excel-compatible):
/// • Booleans in literals become 1/0; booleans coming from cell references are ignored.
/// • Strings that can be parsed as numbers are accepted when literal (not via reference).
/// • Non-numeric values, empty cells, and text are skipped.
/// • Encountered `#ERROR!` values are propagated immediately.
/// • Ranges are flattened cell-by-cell; cross-sheet ranges trigger `#VALUE!`.
///
/// Requires `&mut Model` because range evaluation queries live cell state.
pub(crate) fn collect_numeric_values(
model: &mut Model,
args: &[Node],
cell: CellReferenceIndex,
) -> Result<Vec<f64>, CalcResult> {
let mut values = Vec::new();
for arg in args {
match model.evaluate_node_in_context(arg, cell) {
CalcResult::Number(v) => values.push(v),
CalcResult::Boolean(b) => {
if !matches!(arg, Node::ReferenceKind { .. }) {
values.push(if b { 1.0 } else { 0.0 });
}
}
CalcResult::Range { left, right } => {
if left.sheet != right.sheet {
return Err(CalcResult::new_error(
Error::VALUE,
cell,
"Ranges are in different sheets".to_string(),
));
}
for row in left.row..=right.row {
for column in left.column..=right.column {
match model.evaluate_cell(CellReferenceIndex {
sheet: left.sheet,
row,
column,
}) {
CalcResult::Number(v) => values.push(v),
error @ CalcResult::Error { .. } => return Err(error),
CalcResult::Range { .. } => {
return Err(CalcResult::new_error(
Error::ERROR,
cell,
"Unexpected Range".to_string(),
));
}
_ => {}
}
}
}
}
error @ CalcResult::Error { .. } => return Err(error),
CalcResult::String(s) => {
if !matches!(arg, Node::ReferenceKind { .. }) {
if let Ok(t) = s.parse::<f64>() {
values.push(t);
} else {
return Err(CalcResult::Error {
error: Error::VALUE,
origin: cell,
message: "Argument cannot be cast into number".to_string(),
});
}
}
}
_ => {}
}
}
Ok(values)
}
2 changes: 2 additions & 0 deletions base/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ mod test_issue_155;
mod test_ln;
mod test_log;
mod test_log10;
mod test_median;
mod test_percentage;
mod test_set_functions_error_handling;
mod test_stdev;
mod test_today;
mod test_types;
mod user_model;
27 changes: 27 additions & 0 deletions base/src/test/test_median.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#![allow(clippy::unwrap_used)]

use crate::test::util::new_empty_model;

#[test]
fn test_fn_median_arguments() {
let mut model = new_empty_model();
model._set("A1", "=MEDIAN()");
model.evaluate();

assert_eq!(model._get_text("A1"), *"#ERROR!");
}

#[test]
fn test_fn_median_minimal() {
let mut model = new_empty_model();
model._set("B1", "1");
model._set("B2", "2");
model._set("B3", "3");
model._set("B4", "'2");
// B5 empty
model._set("B6", "true");
model._set("A1", "=MEDIAN(B1:B6)");
model.evaluate();

assert_eq!(model._get_text("A1"), *"2");
}
Loading