cat / 012
clone

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

Add math functions with Sheets semantics

Dami committed d5598f0
SUMIF, SUMIFS, SUMPRODUCT, PRODUCT, POWER, ROUNDUP, ROUNDDOWN, TRUNC,
CEILING, FLOOR, SIGN, EXP, LN, LOG, LOG10, QUOTIENT, RAND and
RANDBETWEEN. Criteria strings (">5", "<>x", wildcards) match as in
Sheets, rounding works on the 15 digits users see, and RAND and
RANDBETWEEN are volatile.
commit
d5598f06ccd24dae15c784a00f6b92006cf43c20
parent
b6d02b382e6d920b21fe645c591beba496600113
author
Dami <dami.etoile@gmail.com>
3 files changed, +354 -0
Ainternal/sheet/functions_math.go217++++++++++++++++++++++++++++++
Ainternal/sheet/functions_math_test.go64+++++++++
Ainternal/sheet/functions_test.go73++++++++++
A internal/sheet/functions_math.go +217 -0
@@ -0,0 +1,217 @@
1+package sheet2+3+import (4+ "math"5+ "math/rand/v2"6+)7+8+func init() {9+ define(10+ &FuncDef{Name: "SUMIF", Args: "range, criterion, [sum_range]", Desc: "Sum of the cells that meet a condition", Min: 2, Max: 3,11+ eval: sumIf, format: inheritFrom(2)},12+ &FuncDef{Name: "SUMIFS", Args: "sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", Desc: "Sum of the cells that meet every condition", Min: 3, Max: -1, step: 2,13+ eval: sumIfs, format: inheritFrom(0)},14+ &FuncDef{Name: "SUMPRODUCT", Args: "array1, [array2, ...]", Desc: "Sum of the products of matching entries", Min: 1, Max: -1,15+ eval: sumProduct},16+ &FuncDef{Name: "PRODUCT", Args: "factor1, [factor2, ...]", Desc: "Product of numbers", Min: 1, Max: -1,17+ eval: aggregate(func(s agg) Value {18+ if s.nums == 0 {19+ return num(0)20+ }21+ return num(s.prod)22+ })},23+ &FuncDef{Name: "POWER", Args: "base, exponent", Desc: "A number raised to a power", Min: 2, Max: 2,24+ eval: numeric(func(x []float64) Value { return power(x[0], x[1]) })},25+ &FuncDef{Name: "ROUNDUP", Args: "value, [places]", Desc: "Round away from zero", Min: 1, Max: 2,26+ eval: rounder(roundUp), format: inheritFrom(0)},27+ &FuncDef{Name: "ROUNDDOWN", Args: "value, [places]", Desc: "Round toward zero", Min: 1, Max: 2,28+ eval: rounder(roundDown), format: inheritFrom(0)},29+ &FuncDef{Name: "TRUNC", Args: "value, [places]", Desc: "Drop decimals past a number of places", Min: 1, Max: 2,30+ eval: rounder(roundDown), format: inheritFrom(0)},31+ &FuncDef{Name: "CEILING", Args: "value, [factor]", Desc: "Round up to a multiple of factor", Min: 1, Max: 2,32+ eval: toMultiple(math.Ceil), format: inheritFrom(0)},33+ &FuncDef{Name: "FLOOR", Args: "value, [factor]", Desc: "Round down to a multiple of factor", Min: 1, Max: 2,34+ eval: toMultiple(math.Floor), format: inheritFrom(0)},35+ &FuncDef{Name: "SIGN", Args: "value", Desc: "1, 0 or -1 by the sign of a number", Min: 1, Max: 1,36+ eval: numeric(func(x []float64) Value {37+ switch {38+ case x[0] > 0:39+ return num(1)40+ case x[0] < 0:41+ return num(-1)42+ }43+ return num(0)44+ })},45+ &FuncDef{Name: "EXP", Args: "exponent", Desc: "e raised to a power", Min: 1, Max: 1, eval: math1(math.Exp)},46+ &FuncDef{Name: "LN", Args: "value", Desc: "Natural logarithm", Min: 1, Max: 1,47+ eval: numeric(func(x []float64) Value {48+ if x[0] <= 0 {49+ return ErrNum50+ }51+ return num(math.Log(x[0]))52+ })},53+ &FuncDef{Name: "LOG", Args: "value, [base]", Desc: "Logarithm, base 10 by default", Min: 1, Max: 2,54+ eval: func(args []Node, get lookup) Value {55+ x, err := numArg(args[0], get)56+ if err != nil {57+ return *err58+ }59+ base, err := optNum(args, 1, 10, get)60+ if err != nil {61+ return *err62+ }63+ switch {64+ case x <= 0 || base <= 0:65+ return ErrNum66+ case base == 1:67+ return ErrDiv068+ }69+ return num(math.Log(x) / math.Log(base))70+ }},71+ &FuncDef{Name: "LOG10", Args: "value", Desc: "Base-10 logarithm", Min: 1, Max: 1,72+ eval: numeric(func(x []float64) Value {73+ if x[0] <= 0 {74+ return ErrNum75+ }76+ return num(math.Log10(x[0]))77+ })},78+ &FuncDef{Name: "QUOTIENT", Args: "dividend, divisor", Desc: "Integer part of a division", Min: 2, Max: 2,79+ eval: numeric(func(x []float64) Value {80+ if x[1] == 0 {81+ return ErrDiv082+ }83+ return num(math.Trunc(x[0] / x[1]))84+ })},85+ &FuncDef{Name: "RAND", Desc: "A random number from 0 up to 1, new on every change", Max: 0, Volatile: true,86+ eval: func([]Node, lookup) Value { return num(rand.Float64()) }},87+ &FuncDef{Name: "RANDBETWEEN", Args: "low, high", Desc: "A random integer between two values, new on every change", Min: 2, Max: 2, Volatile: true,88+ eval: numeric(func(x []float64) Value {89+ lo, hi := math.Ceil(x[0]), math.Floor(x[1])90+ if lo > hi {91+ return ErrNum92+ }93+ return num(lo + math.Floor(rand.Float64()*(hi-lo+1)))94+ })},95+ )96+}97+98+func power(b, e float64) Value {99+ if b == 0 && e < 0 {100+ return ErrDiv0101+ }102+ return num(math.Pow(b, e))103+}104+105+// toMultiple builds CEILING and FLOOR with Sheets semantics: the factor106+// defaults to 1, a zero factor gives 0, and a positive value with a107+// negative factor is #NUM!.108+func toMultiple(round func(float64) float64) func([]Node, lookup) Value {109+ return func(args []Node, get lookup) Value {110+ x, err := numArg(args[0], get)111+ if err != nil {112+ return *err113+ }114+ f, err := optNum(args, 1, 1, get)115+ if err != nil {116+ return *err117+ }118+ switch {119+ case f == 0 || x == 0:120+ return num(0)121+ case x > 0 && f < 0:122+ return ErrNum123+ }124+ q := x / f125+ // Snap quotients like 2.0000000000000004 to the integer they mean.126+ if r := math.Round(q); math.Abs(q-r) < 1e-12*math.Max(1, math.Abs(q)) {127+ q = r128+ }129+ // With both negative the quotient is positive, so CEILING moves130+ // the result away from zero and FLOOR toward it.131+ return num(round(q) * f)132+ }133+}134+135+func sumIf(args []Node, get lookup) Value {136+ rng := matrixArg(args[0], get)137+ cv := eval(args[1], get)138+ if cv.Kind == Error {139+ return cv140+ }141+ c := newCriterion(cv)142+ sum := rng143+ if len(args) > 2 {144+ sum = matrixArg(args[2], get).resized(rng.rows, rng.cols, get)145+ if sum.rows != rng.rows || sum.cols != rng.cols {146+ return ErrValue147+ }148+ }149+ total := 0.0150+ for i := range rng.size() {151+ if !c.test(rng.at(i)) {152+ continue153+ }154+ v := sum.at(i)155+ switch v.Kind {156+ case Error:157+ return v158+ case Number:159+ total += v.Num160+ }161+ }162+ return num(total)163+}164+165+func sumIfs(args []Node, get lookup) Value {166+ sum := matrixArg(args[0], get)167+ rows, cols, mask, err := criteriaMask(args, 1, get)168+ switch {169+ case err != nil:170+ return *err171+ case rows != sum.rows || cols != sum.cols:172+ return ErrValue173+ }174+ total := 0.0175+ for i, ok := range mask {176+ if !ok {177+ continue178+ }179+ v := sum.at(i)180+ switch v.Kind {181+ case Error:182+ return v183+ case Number:184+ total += v.Num185+ }186+ }187+ return num(total)188+}189+190+// sumProduct multiplies same-sized arrays entry by entry and sums; text191+// and blanks count as 0.192+func sumProduct(args []Node, get lookup) Value {193+ ms := make([]matrix, len(args))194+ for i, a := range args {195+ ms[i] = matrixArg(a, get)196+ if ms[i].rows != ms[0].rows || ms[i].cols != ms[0].cols {197+ return ErrValue198+ }199+ }200+ total := 0.0201+ for k := range ms[0].size() {202+ p := 1.0203+ for _, m := range ms {204+ v := m.at(k)205+ switch v.Kind {206+ case Error:207+ return v208+ case Number:209+ p *= v.Num210+ default:211+ p = 0212+ }213+ }214+ total += p215+ }216+ return num(total)217+}
A internal/sheet/functions_math_test.go +64 -0
@@ -0,0 +1,64 @@
1+package sheet2+3+import (4+ "math"5+ "testing"6+)7+8+func TestMathFunctions(t *testing.T) {9+ checkFormulas(t, fixture(t), []fnCase{10+ {`=SUMIF(B1:B5, "north", C1:C5)`, num(9)},11+ {`=SUMIF(A1:A7, ">15")`, num(50)},12+ {`=SUMIF(A1:A7, "<>10")`, num(45)}, // text and booleans aren't summed13+ {`=SUMIF(B1:B5, "n*", C1)`, num(9)}, // sum range resized from its corner14+ {`=SUMIF(C1:C7, 3)`, num(3)},15+ {`=SUMIF(C1:C7, ">=" & C5)`, num(18)},16+ {`=SUMIF(B1:B7, "~*")`, num(0)},17+ {`=SUMIFS(C1:C5, B1:B5, "north", A1:A5, ">10")`, num(3)},18+ {`=SUMIFS(C1:C5, B1:B5, "north", A1:A4, ">10")`, ErrValue}, // mismatched sizes19+ {"=SUMPRODUCT(A1:A3, C1:C3)", num(140)},20+ {"=SUMPRODUCT(A1:A4, C1:C4)", num(140)}, // text counts as 021+ {"=SUMPRODUCT(A1:A3, C1:C4)", ErrValue},22+ {"=PRODUCT(C1:C5)", num(120)},23+ {"=PRODUCT(A4)", num(0)}, // text in references is ignored24+ {"=PRODUCT(2, \"3\")", num(6)},25+ {"=POWER(2, 10)", num(1024)},26+ {"=POWER(0, -1)", ErrDiv0},27+ {"=POWER(-8, 1/3)", ErrNum},28+ {"=ROUND(1.005, 2)", num(1.01)},29+ {"=ROUND(-2.5)", num(-3)},30+ {"=ROUND(1234.5, -2)", num(1200)},31+ {"=ROUNDUP(2.3, 1)", num(2.3)}, // not 2.4 from binary noise32+ {"=ROUNDUP(-2.31, 1)", num(-2.4)},33+ {"=ROUNDUP(1234, -2)", num(1300)},34+ {"=ROUNDDOWN(2.39, 1)", num(2.3)},35+ {"=ROUNDDOWN(-2.39)", num(-2)},36+ {"=TRUNC(-7.9)", num(-7)},37+ {"=CEILING(2.1)", num(3)},38+ {"=CEILING(7, 5)", num(10)},39+ {"=CEILING(0.3, 0.1)", num(0.3)},40+ {"=CEILING(-2.5, 1)", num(-2)},41+ {"=CEILING(-2.5, -1)", num(-3)},42+ {"=CEILING(2.5, -1)", ErrNum},43+ {"=CEILING(5, 0)", num(0)},44+ {"=FLOOR(2.9)", num(2)},45+ {"=FLOOR(7, 5)", num(5)},46+ {"=FLOOR(-2.5, 1)", num(-3)},47+ {"=FLOOR(-2.5, -1)", num(-2)},48+ {"=SIGN(-3)", num(-1)},49+ {"=SIGN(0)", num(0)},50+ {"=EXP(1)", num(math.E)},51+ {"=LN(EXP(2))", num(2)},52+ {"=LN(0)", ErrNum},53+ {"=LOG(8, 2)", num(3)},54+ {"=LOG(1000)", num(3)},55+ {"=LOG(8, 1)", ErrDiv0},56+ {"=LOG(-1)", ErrNum},57+ {"=LOG10(0.01)", num(-2)},58+ {"=QUOTIENT(-7, 2)", num(-3)},59+ {"=QUOTIENT(1, 0)", ErrDiv0},60+ {"=RANDBETWEEN(3, 3)", num(3)},61+ {"=RANDBETWEEN(5, 1)", ErrNum},62+ {"=AND(RAND()>=0, RAND()<1)", boolean(true)},63+ })64+}
A internal/sheet/functions_test.go +73 -0
@@ -0,0 +1,73 @@
1+package sheet2+3+import (4+ "math"5+ "testing"6+)7+8+// fixture is a small sheet the function tests read from.9+//10+// A B C D E F G H I11+// 1 10 north 1 Apple 1.5 9/26/2026 1 -1000 3012+// 2 20 south 2 Banana 2.5 2026-01-31 2 300 2013+// 3 30 north 3 Cherry 3.5 14:30 2 400 1014+// 4 apple east 4 date x 2/29/2024 3 50015+// 5 north 5 Apple 9 12/31/2025 516+// 6 TRUE "" 617+// 7 -5 Ab*d 718+func fixture(t *testing.T) *Sheet {19+ t.Helper()20+ fixedNow(t)21+ s := New()22+ for a, in := range map[string]string{23+ "A1": "10", "A2": "20", "A3": "30", "A4": "apple", "A6": "TRUE", "A7": "-5",24+ "B1": "north", "B2": "south", "B3": "north", "B4": "east", "B5": "north", "B6": `=""`, "B7": "Ab*d",25+ "C1": "1", "C2": "2", "C3": "3", "C4": "4", "C5": "5", "C6": "6", "C7": "7",26+ "D1": "Apple", "D2": "Banana", "D3": "Cherry", "D4": "date", "D5": "Apple",27+ "E1": "1.5", "E2": "2.5", "E3": "3.5", "E4": "x", "E5": "9",28+ "F1": "9/26/2026", "F2": "2026-01-31", "F3": "14:30", "F4": "2/29/2024", "F5": "12/31/2025",29+ "G1": "1", "G2": "2", "G3": "2", "G4": "3",30+ "H1": "-1000", "H2": "300", "H3": "400", "H4": "500",31+ "I1": "30", "I2": "20", "I3": "10",32+ } {33+ if err := s.Set(at(a), in); err != nil {34+ t.Fatalf("%s: %v", a, err)35+ }36+ }37+ return s38+}39+40+type fnCase struct {41+ in string42+ want Value43+}44+45+// checkFormulas evaluates each formula in Z1 and compares, allowing46+// rounding differences in the 12th significant digit.47+func checkFormulas(t *testing.T, s *Sheet, tests []fnCase) {48+ t.Helper()49+ for _, tt := range tests {50+ if err := s.Set(at("Z1"), tt.in); err != nil {51+ t.Errorf("Set(%q): %v", tt.in, err)52+ continue53+ }54+ got := s.Value(at("Z1"))55+ if got.Kind == Number && tt.want.Kind == Number {56+ if d := math.Abs(got.Num - tt.want.Num); d > 1e-9*math.Max(1, math.Abs(tt.want.Num)) {57+ t.Errorf("%s = %v, want %v", tt.in, got.Num, tt.want.Num)58+ }59+ continue60+ }61+ if got != tt.want {62+ t.Errorf("%s = %+v, want %+v", tt.in, got, tt.want)63+ }64+ }65+}66+67+func TestFunctionTable(t *testing.T) {68+ for _, f := range Funcs() {69+ if f.Desc == "" || (f.Max != 0 && f.Args == "") || f.eval == nil {70+ t.Errorf("%s is missing its description, signature or implementation", f.Name)71+ }72+ }73+}