MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE· COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE· EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는 월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게). numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용 이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침). 경계값 시험 180건 전부 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
|
|
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
|
|
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
|
* ========================================================================== */
|
|
|
|
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
|
|
import type {
|
|
ErrorValue,
|
|
EvalContext,
|
|
EvalResult,
|
|
LazyArg,
|
|
RangeValue,
|
|
SheetFunction,
|
|
} from "./spreadsheet_types";
|
|
import {
|
|
collectNumbers,
|
|
fracToDouble,
|
|
fromDouble,
|
|
isErr,
|
|
isFrac,
|
|
isRange,
|
|
mkErr,
|
|
num,
|
|
} from "./spreadsheet_functions_math";
|
|
|
|
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
|
|
const out: Frac[] = [];
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) {
|
|
const cell = range.at(r, c);
|
|
if (isErr(cell)) return cell;
|
|
if (isFrac(cell)) out.push(cell);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
|
|
const n = frac(BigInt(values.length));
|
|
const mean = div(values.reduce(add, ZERO), n);
|
|
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
|
|
return { mean, sumSq };
|
|
}
|
|
|
|
function sampleVariance(values: Frac[]): Frac | ErrorValue {
|
|
if (values.length < 2) return mkErr("#DIV/0!");
|
|
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
|
|
}
|
|
|
|
function populationVariance(values: Frac[]): Frac | ErrorValue {
|
|
if (values.length < 1) return mkErr("#DIV/0!");
|
|
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
|
|
}
|
|
|
|
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
|
|
if (isErr(variance)) return variance;
|
|
return fromDouble(Math.sqrt(fracToDouble(variance)));
|
|
}
|
|
|
|
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
|
|
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
|
|
if (values.length === 0) return mkErr("#NUM!");
|
|
if (k < 0 || k > 1) return mkErr("#NUM!");
|
|
const sorted = [...values].sort((a, b) => cmp(a, b));
|
|
const pos = k * (sorted.length - 1);
|
|
const lowIdx = Math.floor(pos);
|
|
const highIdx = Math.ceil(pos);
|
|
if (lowIdx === highIdx) return sorted[lowIdx];
|
|
const weight = fromDouble(pos - lowIdx);
|
|
if (isErr(weight)) return weight;
|
|
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
|
|
}
|
|
|
|
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
|
|
const values = rangeNumbers(range);
|
|
if (isErr(values)) return values;
|
|
if (k < 1 || k > values.length) return mkErr("#NUM!");
|
|
const sorted = [...values].sort((a, b) => cmp(a, b));
|
|
return sorted[largest ? sorted.length - k : k - 1];
|
|
}
|
|
|
|
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
|
|
const target = num(args[0]());
|
|
if (isErr(target)) return target;
|
|
const range = args[1]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
|
|
if (isErr(orderArg)) return orderArg;
|
|
const ascending = cmp(orderArg, ZERO) !== 0;
|
|
let rank = 1;
|
|
let found = false;
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) {
|
|
const cell = range.at(r, c);
|
|
if (!isFrac(cell)) continue;
|
|
if (cmp(cell, target) === 0) found = true;
|
|
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
|
|
}
|
|
}
|
|
return found ? frac(BigInt(rank)) : mkErr("#N/A");
|
|
}
|
|
|
|
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
|
|
MEDIAN: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
if (isErr(values)) return values;
|
|
if (values.length === 0) return mkErr("#NUM!");
|
|
const sorted = [...values].sort((a, b) => cmp(a, b));
|
|
const mid = Math.floor(sorted.length / 2);
|
|
if (sorted.length % 2 === 1) return sorted[mid];
|
|
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
|
|
},
|
|
},
|
|
MODE: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
if (isErr(values)) return values;
|
|
let bestIdx = -1;
|
|
let bestCount = 1;
|
|
for (let i = 0; i < values.length; i++) {
|
|
let count = 0;
|
|
for (const v of values) if (cmp(values[i], v) === 0) count++;
|
|
if (count > bestCount) {
|
|
bestCount = count;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
|
|
},
|
|
},
|
|
STDEV: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
|
},
|
|
},
|
|
"STDEV.S": {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
|
},
|
|
},
|
|
"STDEV.P": {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
return isErr(values) ? values : stdevOf(populationVariance(values));
|
|
},
|
|
},
|
|
VAR: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, true, true);
|
|
return isErr(values) ? values : sampleVariance(values);
|
|
},
|
|
},
|
|
RANK: { min: 2, max: 3, call: rankCall },
|
|
"RANK.EQ": { min: 2, max: 3, call: rankCall },
|
|
LARGE: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const kArg = num(args[1]());
|
|
if (isErr(kArg)) return kArg;
|
|
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
|
|
},
|
|
},
|
|
SMALL: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const kArg = num(args[1]());
|
|
if (isErr(kArg)) return kArg;
|
|
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
|
|
},
|
|
},
|
|
PERCENTILE: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const kArg = num(args[1]());
|
|
if (isErr(kArg)) return kArg;
|
|
const values = rangeNumbers(range);
|
|
if (isErr(values)) return values;
|
|
return percentileOf(values, fracToDouble(kArg));
|
|
},
|
|
},
|
|
QUARTILE: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const qArg = num(args[1]());
|
|
if (isErr(qArg)) return qArg;
|
|
const q = Math.trunc(fracToDouble(qArg));
|
|
if (q < 0 || q > 4) return mkErr("#NUM!");
|
|
const values = rangeNumbers(range);
|
|
if (isErr(values)) return values;
|
|
return percentileOf(values, q / 4);
|
|
},
|
|
},
|
|
COUNTBLANK: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
let count = 0;
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
|
|
}
|
|
return frac(BigInt(count));
|
|
},
|
|
},
|
|
};
|