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>
| A | internal/ | 217 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 64 | +++++++++ |
| A | internal/ | 73 | ++++++++++ |
@@ -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+}@@ -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+}@@ -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+}