cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add statistical functions

Dami committed 5bb28c9
AVERAGEIF, AVERAGEIFS, COUNTIF, COUNTIFS, COUNTBLANK, MEDIAN, MODE,
STDEV, STDEVP, VAR, VARP, LARGE, SMALL and RANK.
commit
5bb28c9c7cddb2237b3a42aa3ee96275058a1968
parent
d5598f06ccd24dae15c784a00f6b92006cf43c20
author
Dami <dami.etoile@gmail.com>
2 files changed, +274 -0
Ainternal/sheet/functions_stats.go228++++++++++++++++++++++++++++++
Ainternal/sheet/functions_stats_test.go46++++++
A internal/sheet/functions_stats.go +228 -0
@@ -0,0 +1,228 @@
1+package sheet2+3+import (4+ "math"5+ "slices"6+)7+8+func init() {9+ define(10+ &FuncDef{Name: "AVERAGEIF", Args: "criteria_range, criterion, [average_range]", Desc: "Average of the cells that meet a condition", Min: 2, Max: 3,11+ eval: func(args []Node, get lookup) Value {12+ rng := matrixArg(args[0], get)13+ cv := eval(args[1], get)14+ if cv.Kind == Error {15+ return cv16+ }17+ c := newCriterion(cv)18+ avg := rng19+ if len(args) > 2 {20+ avg = matrixArg(args[2], get).resized(rng.rows, rng.cols, get)21+ }22+ mask := make([]bool, rng.size())23+ for i := range mask {24+ mask[i] = c.test(rng.at(i))25+ }26+ return averageMasked(avg, mask)27+ }, format: inheritFrom(2)},28+ &FuncDef{Name: "AVERAGEIFS", Args: "average_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Average of the cells that meet every condition", Min: 3, Max: -1, step: 2,29+ eval: func(args []Node, get lookup) Value {30+ avg := matrixArg(args[0], get)31+ rows, cols, mask, err := criteriaMask(args, 1, get)32+ switch {33+ case err != nil:34+ return *err35+ case rows != avg.rows || cols != avg.cols:36+ return ErrValue37+ }38+ return averageMasked(avg, mask)39+ }, format: inheritFrom(0)},40+ &FuncDef{Name: "COUNTIF", Args: "range, criterion", Desc: "Count of the cells that meet a condition", Min: 2, Max: 2,41+ eval: countIfs},42+ &FuncDef{Name: "COUNTIFS", Args: "criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Count of the cells that meet every condition", Min: 2, Max: -1, step: 2,43+ eval: countIfs},44+ &FuncDef{Name: "COUNTBLANK", Args: "range", Desc: "Count of empty cells, including empty text", Min: 1, Max: 1,45+ eval: func(args []Node, get lookup) Value {46+ m := matrixArg(args[0], get)47+ n := 048+ for i := range m.size() {49+ if v := m.at(i); v.Kind == Empty || v.Kind == Text && v.Str == "" {50+ n++51+ }52+ }53+ return num(float64(n))54+ }},55+ &FuncDef{Name: "MEDIAN", Args: "value1, [value2, ...]", Desc: "Middle value of numbers", Min: 1, Max: -1,56+ eval: withNums(func(x []float64) Value {57+ if len(x) == 0 {58+ return ErrNum59+ }60+ slices.Sort(x)61+ n := len(x)62+ if n%2 == 1 {63+ return num(x[n/2])64+ }65+ return num((x[n/2-1] + x[n/2]) / 2)66+ }), format: inherit},67+ &FuncDef{Name: "MODE", Args: "value1, [value2, ...]", Desc: "Most common number (the first, on a tie)", Min: 1, Max: -1,68+ eval: withNums(func(x []float64) Value {69+ best, bestN := 0.0, 170+ for i, v := range x {71+ n := 072+ for _, w := range x[i:] {73+ if w == v {74+ n++75+ }76+ }77+ if n > bestN {78+ best, bestN = v, n79+ }80+ }81+ if bestN < 2 {82+ return ErrNA83+ }84+ return num(best)85+ }), format: inherit},86+ &FuncDef{Name: "STDEV", Args: "value1, [value2, ...]", Desc: "Standard deviation of a sample", Min: 1, Max: -1,87+ eval: withNums(func(x []float64) Value { return spread(x, 1, true) })},88+ &FuncDef{Name: "STDEVP", Args: "value1, [value2, ...]", Desc: "Standard deviation of a whole population", Min: 1, Max: -1,89+ eval: withNums(func(x []float64) Value { return spread(x, 0, true) })},90+ &FuncDef{Name: "VAR", Args: "value1, [value2, ...]", Desc: "Variance of a sample", Min: 1, Max: -1,91+ eval: withNums(func(x []float64) Value { return spread(x, 1, false) })},92+ &FuncDef{Name: "VARP", Args: "value1, [value2, ...]", Desc: "Variance of a whole population", Min: 1, Max: -1,93+ eval: withNums(func(x []float64) Value { return spread(x, 0, false) })},94+ &FuncDef{Name: "LARGE", Args: "data, n", Desc: "The nth largest number", Min: 2, Max: 2,95+ eval: nth(func(a, b float64) int { return cmpFloat(b, a) }), format: inheritFrom(0)},96+ &FuncDef{Name: "SMALL", Args: "data, n", Desc: "The nth smallest number", Min: 2, Max: 2,97+ eval: nth(cmpFloat), format: inheritFrom(0)},98+ &FuncDef{Name: "RANK", Args: "value, data, [is_ascending]", Desc: "Rank of a number among others, largest first by default", Min: 2, Max: 3,99+ eval: func(args []Node, get lookup) Value {100+ v, err := numArg(args[0], get)101+ if err != nil {102+ return *err103+ }104+ data, err := nums(args[1:2], get)105+ if err != nil {106+ return *err107+ }108+ asc, err := boolArg(args, 2, false, get)109+ if err != nil {110+ return *err111+ }112+ rank, found := 1, false113+ for _, d := range data {114+ switch {115+ case d == v:116+ found = true117+ case asc && d < v, !asc && d > v:118+ rank++119+ }120+ }121+ if !found {122+ return ErrNA123+ }124+ return num(float64(rank))125+ }},126+ )127+}128+129+func countIfs(args []Node, get lookup) Value {130+ _, _, mask, err := criteriaMask(args, 0, get)131+ if err != nil {132+ return *err133+ }134+ n := 0135+ for _, ok := range mask {136+ if ok {137+ n++138+ }139+ }140+ return num(float64(n))141+}142+143+// averageMasked averages the numbers of m where mask is set.144+func averageMasked(m matrix, mask []bool) Value {145+ sum, n := 0.0, 0146+ for i, ok := range mask {147+ if !ok {148+ continue149+ }150+ v := m.at(i)151+ switch v.Kind {152+ case Error:153+ return v154+ case Number:155+ sum += v.Num156+ n++157+ }158+ }159+ if n == 0 {160+ return ErrDiv0161+ }162+ return num(sum / float64(n))163+}164+165+// withNums passes the numbers of all arguments, with aggregate166+// semantics, to f.167+func withNums(f func([]float64) Value) func([]Node, lookup) Value {168+ return func(args []Node, get lookup) Value {169+ x, err := nums(args, get)170+ if err != nil {171+ return *err172+ }173+ return f(x)174+ }175+}176+177+// spread computes variance (or its root) with ddof 1 for samples and 0178+// for populations.179+func spread(x []float64, ddof int, root bool) Value {180+ n := len(x)181+ if n-ddof <= 0 {182+ return ErrDiv0183+ }184+ mean := 0.0185+ for _, v := range x {186+ mean += v187+ }188+ mean /= float64(n)189+ ss := 0.0190+ for _, v := range x {191+ ss += (v - mean) * (v - mean)192+ }193+ v := ss / float64(n-ddof)194+ if root {195+ v = math.Sqrt(v)196+ }197+ return num(v)198+}199+200+// nth builds LARGE and SMALL: the nth number of data in cmp order.201+func nth(cmp func(a, b float64) int) func([]Node, lookup) Value {202+ return func(args []Node, get lookup) Value {203+ x, err := nums(args[:1], get)204+ if err != nil {205+ return *err206+ }207+ n, err := numArg(args[1], get)208+ if err != nil {209+ return *err210+ }211+ k := int(math.Ceil(n))212+ if k < 1 || k > len(x) {213+ return ErrNum214+ }215+ slices.SortFunc(x, cmp)216+ return num(x[k-1])217+ }218+}219+220+func cmpFloat(a, b float64) int {221+ switch {222+ case a < b:223+ return -1224+ case a > b:225+ return 1226+ }227+ return 0228+}
A internal/sheet/functions_stats_test.go +46 -0
@@ -0,0 +1,46 @@
1+package sheet2+3+import "testing"4+5+func TestStatsFunctions(t *testing.T) {6+ checkFormulas(t, fixture(t), []fnCase{7+ {`=AVERAGEIF(B1:B5, "north", C1:C5)`, num(3)},8+ {`=AVERAGEIF(A1:A7, ">0")`, num(20)},9+ {`=AVERAGEIF(A1:A7, ">100")`, ErrDiv0},10+ {`=AVERAGEIFS(C1:C5, B1:B5, "north", C1:C5, ">1")`, num(4)},11+ {`=COUNTIF(B1:B7, "north")`, num(3)},12+ {`=COUNTIF(B1:B7, "NORTH")`, num(3)}, // case-insensitive13+ {`=COUNTIF(B1:B7, "?o*")`, num(4)},14+ {`=COUNTIF(B1:B7, "Ab~*d")`, num(1)},15+ {`=COUNTIF(A1:A7, "<>10")`, num(6)}, // blanks and text count as not equal16+ {`=COUNTIF(A1:A7, "")`, num(1)},17+ {`=COUNTIF(B1:B7, "")`, num(1)}, // "" from a formula counts as blank18+ {`=COUNTIF(A1:A7, "<>")`, num(6)},19+ {`=COUNTIF(A1:A7, TRUE)`, num(1)},20+ {`=COUNTIF(A1:A7, ">=10")`, num(3)},21+ {`=COUNTIF(F1:F5, ">1/1/2026")`, num(2)}, // date criteria compare as dates22+ {`=COUNTIF(B1:B7, ">m")`, num(4)},23+ {`=COUNTIFS(B1:B5, "north", C1:C5, "<5")`, num(2)},24+ {"=COUNTBLANK(A1:A7)", num(1)},25+ {"=COUNTBLANK(B1:B7)", num(1)},26+ {"=MEDIAN(A1:A7)", num(15)},27+ {"=MEDIAN(C1:C5)", num(3)},28+ {"=MEDIAN(A4)", ErrNum},29+ {`=MEDIAN("a")`, ErrValue},30+ {"=MODE(G1:G4)", num(2)},31+ {"=MODE(C1:C5)", ErrNA},32+ {"=STDEV(A1:A7)", num(14.930394055974098)},33+ {"=STDEVP(A1:A7)", num(12.93010054098575)},34+ {"=VAR(A1:A7)", num(222.91666666666666)},35+ {"=VARP(A1:A7)", num(167.1875)},36+ {"=STDEV(5)", ErrDiv0},37+ {"=LARGE(A1:A7, 1)", num(30)},38+ {"=LARGE(A1:A7, 4)", num(-5)},39+ {"=LARGE(A1:A7, 5)", ErrNum},40+ {"=SMALL(C1:C7, 2)", num(2)},41+ {"=RANK(20, A1:A7)", num(2)},42+ {"=RANK(20, A1:A7, 1)", num(3)},43+ {"=RANK(2, G1:G4)", num(2)},44+ {"=RANK(99, A1:A7)", ErrNA},45+ })46+}