cat / 012
clone

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

Functions: PERCENTILE, QUARTILE, their .INC and .EXC, MODE.SNGL and RANK.EQ

Dami committed 7920678
MEDIAN, MODE, LARGE, SMALL and the new percentiles read their numbers
once in ascending order through one interface (Sorted), held and
sorted on a sheet, so a Book can hand them out sorted some other way.
RANK counts above and below as it reads rather than holding the range.
Excel's _xlfn. prefix is written for the dotted names.
commit
7920678c606cb21f8a298c68c3b5886776e35af6
parent
d59b5ded81a8238b7c4169656d5857066844b4ee
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.0 of 3, Jev's estimate
6 files changed, +318 -68
Mdocs/reference/functions.md10+-
Minternal/fileio/formula.go2+
Ainternal/functions/order.go236++++++++++++++++++++++++++++++
Minternal/functions/stats.go114++++++---------
Minternal/sheet/functions_stats_test.go19++
Moracle/oracle_test.go5+
M docs/reference/functions.md +9 -1
@@ -7,7 +7,7 @@ sidebar_position: 3
77 88 <!-- Generated from the engine's function table by TestFunctionsDoc; do not edit. -->99 10-012 has 142 functions. They follow Google Sheets' names, arguments and10+012 has 150 functions. They follow Google Sheets' names, arguments and1111 semantics; `[brackets]` mark optional arguments. Function names are1212 case-insensitive, and 1-2-3's `@SUM(A1..A5)` spelling still works.1313
@@ -73,7 +73,15 @@ Aliases: `AVG` for `AVERAGE`.
7373 | `LARGE(data, n)` | The nth largest number |7474 | `MEDIAN(value1, [value2, ...])` | Middle value of numbers |7575 | `MODE(value1, [value2, ...])` | Most common number (the first, on a tie) |76+| `MODE.SNGL(value1, [value2, ...])` | Most common number, the first on a tie (MODE) |77+| `PERCENTILE(data, percentile)` | The value at a percentile from 0 to 1, interpolated |78+| `PERCENTILE.EXC(data, percentile)` | The value at a percentile strictly between 0 and 1, interpolated |79+| `PERCENTILE.INC(data, percentile)` | The value at a percentile from 0 to 1, interpolated (PERCENTILE) |80+| `QUARTILE(data, quartile)` | The minimum (0), a quartile (1 to 3) or the maximum (4) |81+| `QUARTILE.EXC(data, quartile)` | A quartile from 1 to 3, between the numbers rather than at them |82+| `QUARTILE.INC(data, quartile)` | The minimum (0), a quartile (1 to 3) or the maximum (4) (QUARTILE) |7683 | `RANK(value, data, [is_ascending])` | Rank of a number among others, largest first by default |84+| `RANK.EQ(value, data, [is_ascending])` | Rank of a number among others, largest first by default (RANK) |7785 | `SMALL(data, n)` | The nth smallest number |7886 | `STDEV(value1, [value2, ...])` | Standard deviation of a sample |7987 | `STDEVP(value1, [value2, ...])` | Standard deviation of a whole population |
M internal/fileio/formula.go +2 -0
A internal/functions/order.go +236 -0
@@ -0,0 +1,236 @@
1+package functions2+3+import (4+ "cmp"5+ "math"6+ "slices"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+ "github.com/FelineStateMachine/012/internal/value"10+)11+12+// Order statistics: MEDIAN, MODE, PERCENTILE, QUARTILE, LARGE and SMALL13+// read their numbers once, in ascending order, picking the ones they14+// need as they pass. On a sheet the numbers are held and sorted; over a15+// linked source's column, computing a StreamCall, the Book sorts them16+// on disk (Sorter), so a source of any size takes the memory of a sort17+// run rather than of its column. Either way the same code picks, so the18+// results are the same.19+20+// Sorted is numbers read in ascending order, once.21+type Sorted interface {22+ // Len counts the numbers.23+ Len() int24+ // Next is the next number, ties in the order they were read, with25+ // its position in that order; false at the end, or on an error,26+ // which Err says.27+ Next() (v float64, pos int64, ok bool)28+ Err() error29+ Close() error30+}31+32+// Sorter is a Book that sorts the numbers of a range of a paged sheet33+// (a linked source's column) on disk: its numbers, skipping the cells34+// holding anything else, as a range's are read by SUM, or the first35+// error in it.36+type Sorter interface {37+ SortedNums(sheet string, r Rect) (Sorted, *Value)38+}39+40+// held is numbers held in memory, sorted.41+type held struct {42+ x []posNum43+ i int44+}45+46+type posNum struct {47+ v float6448+ pos int6449+}50+51+func (h *held) Len() int { return len(h.x) }52+func (h *held) Err() error { return nil }53+func (h *held) Close() error { return nil }54+func (h *held) Next() (float64, int64, bool) {55+ if h.i >= len(h.x) {56+ return 0, 0, false57+ }58+ h.i++59+ return h.x[h.i-1].v, h.x[h.i-1].pos, true60+}61+62+// sortedNums is the numbers of args in ascending order, with aggregate63+// semantics (nums): sorted by the Book on disk when computing a64+// StreamCall over one range of a source, else held.65+func sortedNums(args []Node, get lookup) (Sorted, *Value) {66+ if s, ok := get.book.(Sorter); ok && get.stream && len(args) == 1 {67+ if r, ok := get.refOf(args[0]).(formula.Range); ok && r.Sheet != "" {68+ return s.SortedNums(r.Sheet, r.Rect)69+ }70+ }71+ x, err := nums(args, get)72+ if err != nil {73+ return nil, err74+ }75+ h := &held{x: make([]posNum, len(x))}76+ for i, v := range x {77+ h.x[i] = posNum{v, int64(i)}78+ }79+ slices.SortFunc(h.x, func(a, b posNum) int { return cmp.Or(cmpFloat(a.v, b.v), cmp.Compare(a.pos, b.pos)) })80+ return h, nil81+}82+83+// pickAt reads the numbers at the ascending indexes ks of s, in order.84+func pickAt(s Sorted, ks ...int) ([]float64, *Value) {85+ out := make([]float64, 0, len(ks))86+ for i := 0; len(out) < len(ks); i++ {87+ v, _, ok := s.Next()88+ if !ok {89+ return nil, &value.ErrRef // the source failed as it was read90+ }91+ for len(out) < len(ks) && ks[len(out)] == i {92+ out = append(out, v)93+ }94+ }95+ return out, nil96+}97+98+// withSorted passes the numbers of the first argument, or with all of99+// every argument (MEDIAN, MODE), sorted, to f with the rest.100+func withSorted(all bool, f func(s Sorted, rest []Node, get lookup) Value) func([]Node, lookup) Value {101+ return func(args []Node, get lookup) Value {102+ upto := 1103+ if all {104+ upto = len(args)105+ }106+ s, err := sortedNums(args[:upto], get)107+ if err != nil {108+ return *err109+ }110+ defer s.Close()111+ return f(s, args[upto:], get)112+ }113+}114+115+func medianOf(s Sorted, _ []Node, _ lookup) Value {116+ n := s.Len()117+ if n == 0 {118+ return value.ErrNum119+ }120+ if n%2 == 1 {121+ x, err := pickAt(s, n/2)122+ if err != nil {123+ return *err124+ }125+ return num(x[0])126+ }127+ x, err := pickAt(s, n/2-1, n/2)128+ if err != nil {129+ return *err130+ }131+ return num((x[0] + x[1]) / 2)132+}133+134+// modeOf is the most common number, the one read first on a tie, or135+// #N/A when none repeats: one pass over the runs of equal numbers.136+func modeOf(s Sorted, _ []Node, _ lookup) Value {137+ best, bestN, bestPos := 0.0, 1, int64(math.MaxInt64)138+ cur, n, first := 0.0, 0, int64(0)139+ end := func() {140+ if n > bestN || n == bestN && n > 1 && first < bestPos {141+ best, bestN, bestPos = cur, n, first142+ }143+ }144+ for {145+ v, pos, ok := s.Next()146+ if !ok {147+ break148+ }149+ if n > 0 && v == cur {150+ n++151+ continue152+ }153+ end()154+ cur, n, first = v, 1, pos155+ }156+ if s.Err() != nil {157+ return value.ErrRef158+ }159+ end()160+ if bestN < 2 {161+ return value.ErrNA162+ }163+ return num(best)164+}165+166+// nthOf builds LARGE and SMALL: the kth largest or smallest number.167+func nthOf(largest bool) func(Sorted, []Node, lookup) Value {168+ return func(s Sorted, rest []Node, get lookup) Value {169+ n, err := numArg(rest[0], get)170+ if err != nil {171+ return *err172+ }173+ k := int(math.Ceil(n))174+ if k < 1 || k > s.Len() {175+ return value.ErrNum176+ }177+ if largest {178+ k = s.Len() - k + 1179+ }180+ x, err := pickAt(s, k-1)181+ if err != nil {182+ return *err183+ }184+ return num(x[0])185+ }186+}187+188+// percentileOf builds PERCENTILE.INC (exc false) and PERCENTILE.EXC,189+// and with quart QUARTILE.INC and QUARTILE.EXC, whose argument counts190+// quarters.191+func percentileOf(exc, quart bool) func(Sorted, []Node, lookup) Value {192+ return func(s Sorted, rest []Node, get lookup) Value {193+ p, err := numArg(rest[0], get)194+ if err != nil {195+ return *err196+ }197+ if quart {198+ q := math.Trunc(p)199+ if q < 0 || q > 4 || exc && (q < 1 || q > 3) {200+ return value.ErrNum201+ }202+ p = q / 4203+ }204+ return percentile(s, p, exc)205+ }206+}207+208+// percentile interpolates between the numbers either side of rank p of209+// s: (n-1)p counting from 0, or (n+1)p counting from 1 for the210+// exclusive kind, which has no rank before the first or past the last.211+func percentile(s Sorted, p float64, exc bool) Value {212+ n := s.Len()213+ if n == 0 || p < 0 || p > 1 || exc && (p <= 0 || p >= 1) {214+ return value.ErrNum215+ }216+ h := float64(n-1) * p217+ if exc {218+ h = float64(n+1)*p - 1219+ if h < 0 || h > float64(n-1) {220+ return value.ErrNum221+ }222+ }223+ lo := int(math.Floor(h))224+ if lo >= n-1 {225+ x, err := pickAt(s, n-1)226+ if err != nil {227+ return *err228+ }229+ return num(x[0])230+ }231+ x, err := pickAt(s, lo, lo+1)232+ if err != nil {233+ return *err234+ }235+ return num(x[0] + (h-float64(lo))*(x[1]-x[0]))236+}
M internal/functions/stats.go +47 -67
@@ -2,7 +2,6 @@ package functions
22 33 import (44 "math"5- "slices"65 76 "github.com/FelineStateMachine/012/internal/value"87 )
@@ -20,9 +19,9 @@ func init() {
2019 &FuncDef{Name: "COUNTBLANK", Args: "range", Desc: "Count of empty cells, including empty text", Min: 1, Max: 1,2120 eval: countBlank},2221 &FuncDef{Name: "MEDIAN", Args: "value1, [value2, ...]", Desc: "Middle value of numbers", Min: 1, Max: -1,23- eval: withNums(median), format: inherit},22+ eval: withSorted(true, medianOf), format: inherit},2423 &FuncDef{Name: "MODE", Args: "value1, [value2, ...]", Desc: "Most common number (the first, on a tie)", Min: 1, Max: -1,25- eval: withNums(mode), format: inherit},24+ eval: withSorted(true, modeOf), format: inherit},2625 &FuncDef{Name: "STDEV", Args: "value1, [value2, ...]", Desc: "Standard deviation of a sample", Min: 1, Max: -1,2726 eval: withNums(func(x []float64) Value { return spread(x, 1, true) })},2827 &FuncDef{Name: "STDEVP", Args: "value1, [value2, ...]", Desc: "Standard deviation of a whole population", Min: 1, Max: -1,
@@ -32,53 +31,60 @@ func init() {
3231 &FuncDef{Name: "VARP", Args: "value1, [value2, ...]", Desc: "Variance of a whole population", Min: 1, Max: -1,3332 eval: withNums(func(x []float64) Value { return spread(x, 0, false) })},3433 &FuncDef{Name: "LARGE", Args: "data, n", Desc: "The nth largest number", Min: 2, Max: 2,35- eval: nth(func(a, b float64) int { return cmpFloat(b, a) }), format: inheritFrom(0)},34+ eval: withSorted(false, nthOf(true)), format: inheritFrom(0)},3635 &FuncDef{Name: "SMALL", Args: "data, n", Desc: "The nth smallest number", Min: 2, Max: 2,37- eval: nth(cmpFloat), format: inheritFrom(0)},36+ eval: withSorted(false, nthOf(false)), format: inheritFrom(0)},3837 &FuncDef{Name: "RANK", Args: "value, data, [is_ascending]", Desc: "Rank of a number among others, largest first by default", Min: 2, Max: 3,3938 eval: rank},39+ &FuncDef{Name: "RANK.EQ", Args: "value, data, [is_ascending]", Desc: "Rank of a number among others, largest first by default (RANK)", Min: 2, Max: 3,40+ eval: rank},41+ &FuncDef{Name: "MODE.SNGL", Args: "value1, [value2, ...]", Desc: "Most common number, the first on a tie (MODE)", Min: 1, Max: -1,42+ eval: withSorted(true, modeOf), format: inherit},43+ &FuncDef{Name: "PERCENTILE", Args: "data, percentile", Desc: "The value at a percentile from 0 to 1, interpolated", Min: 2, Max: 2,44+ eval: withSorted(false, percentileOf(false, false)), format: inheritFrom(0)},45+ &FuncDef{Name: "PERCENTILE.INC", Args: "data, percentile", Desc: "The value at a percentile from 0 to 1, interpolated (PERCENTILE)", Min: 2, Max: 2,46+ eval: withSorted(false, percentileOf(false, false)), format: inheritFrom(0)},47+ &FuncDef{Name: "PERCENTILE.EXC", Args: "data, percentile", Desc: "The value at a percentile strictly between 0 and 1, interpolated", Min: 2, Max: 2,48+ eval: withSorted(false, percentileOf(true, false)), format: inheritFrom(0)},49+ &FuncDef{Name: "QUARTILE", Args: "data, quartile", Desc: "The minimum (0), a quartile (1 to 3) or the maximum (4)", Min: 2, Max: 2,50+ eval: withSorted(false, percentileOf(false, true)), format: inheritFrom(0)},51+ &FuncDef{Name: "QUARTILE.INC", Args: "data, quartile", Desc: "The minimum (0), a quartile (1 to 3) or the maximum (4) (QUARTILE)", Min: 2, Max: 2,52+ eval: withSorted(false, percentileOf(false, true)), format: inheritFrom(0)},53+ &FuncDef{Name: "QUARTILE.EXC", Args: "data, quartile", Desc: "A quartile from 1 to 3, between the numbers rather than at them", Min: 2, Max: 2,54+ eval: withSorted(false, percentileOf(true, true)), format: inheritFrom(0)},4055 )4156 }4257 4358 // Evaluators for the table above, in its order.4459 45-func median(x []float64) Value {46- if len(x) == 0 {47- return value.ErrNum48- }49- slices.Sort(x)50- n := len(x)51- if n%2 == 1 {52- return num(x[n/2])53- }54- return num((x[n/2-1] + x[n/2]) / 2)55-}56-57-func mode(x []float64) Value {58- best, bestN := 0.0, 159- for i, v := range x {60- n := 061- for _, w := range x[i:] {62- if w == v {63- n++64- }65- }66- if n > bestN {67- best, bestN = v, n68- }69- }70- if bestN < 2 {71- return value.ErrNA72- }73- return num(best)74-}75-60+// rank counts the numbers of data above and below the value as it reads61+// them, holding none, so a linked source's column of any size streams.7662 func rank(args []Node, get lookup) Value {7763 v, err := numArg(args[0], get)7864 if err != nil {7965 return *err8066 }81- data, err := nums(args[1:2], get)67+ above, below, found := 0, 0, false68+ err = each(args[1:2], get, func(d Value, direct bool) *Value {69+ switch {70+ case d.Kind == value.Error:71+ return errOf(d)72+ case d.Kind == value.Empty, d.Kind != value.Number && !direct:73+ return nil74+ }75+ f, err := toNum(d)76+ switch {77+ case err != nil:78+ return err79+ case f == v:80+ found = true81+ case f > v:82+ above++83+ default:84+ below++85+ }86+ return nil87+ })8288 if err != nil {8389 return *err8490 }
@@ -86,19 +92,13 @@ func rank(args []Node, get lookup) Value {
8692 if err != nil {8793 return *err8894 }89- rank, found := 1, false90- for _, d := range data {91- switch {92- case d == v:93- found = true94- case asc && d < v, !asc && d > v:95- rank++96- }97- }9895 if !found {9996 return value.ErrNA10097 }101- return num(float64(rank))98+ if asc {99+ return num(float64(below + 1))100+ }101+ return num(float64(above + 1))102102 }103103 104104 // withNums passes the numbers of all arguments, with aggregate
@@ -136,26 +136,6 @@ func spread(x []float64, ddof int, root bool) Value {
136136 return num(v)137137 }138138 139-// nth builds LARGE and SMALL: the nth number of data in cmp order.140-func nth(cmp func(a, b float64) int) func([]Node, lookup) Value {141- return func(args []Node, get lookup) Value {142- x, err := nums(args[:1], get)143- if err != nil {144- return *err145- }146- n, err := numArg(args[1], get)147- if err != nil {148- return *err149- }150- k := int(math.Ceil(n))151- if k < 1 || k > len(x) {152- return value.ErrNum153- }154- slices.SortFunc(x, cmp)155- return num(x[k-1])156- }157-}158-159139 func cmpFloat(a, b float64) int {160140 switch {161141 case a < b:
M internal/sheet/functions_stats_test.go +19 -0
M oracle/oracle_test.go +5 -0
@@ -62,6 +62,10 @@ var formulas = []string{
6262 `=MEDIAN(A1:A7)`, `=MEDIAN(C1:C5)`, `=MODE(G1:G4)`, `=MODE(C1:C5)`, `=STDEV(A1:A7)`, `=STDEVP(A1:A7)`,6363 `=VAR(A1:A7)`, `=VARP(A1:A7)`, `=STDEV(5)`, `=LARGE(A1:A7, 1)`, `=LARGE(A1:A7, 4)`, `=LARGE(A1:A7, 5)`,6464 `=SMALL(C1:C7, 2)`, `=RANK(20, A1:A7)`, `=RANK(20, A1:A7, 1)`, `=RANK(2, G1:G4)`, `=RANK(99, A1:A7)`,65+ `=RANK.EQ(20, A1:A7)`, `=MODE.SNGL(G1:G4)`, `=PERCENTILE(A1:A7, 0.25)`, `=PERCENTILE.INC(A1:A7, 0.9)`,66+ `=PERCENTILE(A1:A7, 1.5)`, `=PERCENTILE.EXC(C1:C5, 0.25)`, `=PERCENTILE.EXC(C1:C5, 0.5)`,67+ `=QUARTILE(C1:C5, 1)`, `=QUARTILE(A1:A7, 3)`, `=QUARTILE.INC(C1:C5, 4)`, `=QUARTILE.EXC(C1:C5, 3)`,68+ `=QUARTILE.EXC(C1:C5, 0)`,6569 // Text6670 `=CONCATENATE("a", 1, TRUE)`, `=CONCAT(A1, "x")`, `=TEXTJOIN(", ", TRUE, A3:A5)`, `=TEXTJOIN("-", FALSE, A3:A5)`,6771 `=LEFT("hello", 2)`, `=LEFT("hello")`, `=LEFT("hi", 10)`, `=LEFT("hi", -1)`, `=RIGHT("hello", 3)`,
@@ -117,6 +121,7 @@ var skipped = map[string]string{
117121 `=CHOOSE(4, "a", "b", "c")`: "Sheets gives #NUM! for an index past the choices; Excel gives #VALUE!",118122 119123 // excelize departs from Excel (and Sheets) here.124+ `=PERCENTILE(A1:A7, 1.5)`: "excelize gives #N/A for a percentile past 1; Excel and Sheets give #NUM!",120125 `=SUM(C1:C2, E6)`: "excelize counts TRUE in a referenced cell as 1; Excel and Sheets ignore it",121126 `=SUMIF(B1:B5, "n*", C1)`: "excelize doesn't stretch a smaller sum range from its corner as Excel does",122127 `=SUMPRODUCT(A1:A4, C1:C4)`: "excelize gives #VALUE! for text; Excel and Sheets treat it as 0",