Files
Aislo/A00_Common/spreadsheet/spreadsheet_functions_stat.ts
T
eomsangdonandClaude Sonnet 5 4804caa9bc feat(spreadsheet): 통계 함수 13개 · 날짜 함수 9개 · numfmt 날짜 형식
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
2026-09-27 21:18:23 +09:00

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));
},
},
};