/* * 예측 정확도 진단 — SPC·정확도·편향·보정 계산의 canonical 구현(서버 소유). * 프레젠테이션(클라)이 아니라 여기에서 한 번 계산해 twinForecastGapTrend 가 돌려준다 → API·twin-ai·경보·다른 UI 재사용, 서버 테스트 가능. * * 두 축(독립): * 안정성(stability) — 오차 폭이 일정한가(SPC I-chart: 관리한계 이탈=shift, 단조 추세=drift). * 편향(bias) — 평균 오차(CL)가 목표 0 에서 벗어났나(CL 이 관리폭 절반 이상 이탈 또는 추적신호 큼). * 관리도는 자기 평균(CL)을 중심으로 잡으므로 "안정적"이 "무편향"을 뜻하지 않는다 — 그래서 별도 축. */ /** SPC I-chart 표본 수 — 신뢰할 만한 관리한계의 통설 최소(20~25) 하한. 서버가 관장(표현 계층 아님). */ export const GAP_SAMPLES = 24 export interface GapPoint { t: number p50: number min: number max: number actual: number error: number // 실제 − 예측(p50). 양수 = 과소예측(예측이 낮음). inBand: boolean naiveAbsError: number modelAbsError: number } /** 합산 결과 + **무엇을 못 넣었는지.** 알리지 않고 줄이지 않는다. */ export interface AggregateResult { points: GapPoint[] /** 한 트윈이라도 재지 못해 통째로 뺀 vantage 수. */ droppedVantages: number } /** * 공동배치(co-located) 트윈들의 점 배열을 공장 단위 한 시계열로 합산. * * ── 예전에 무엇이 어긋나 있었나 (2026-08-08) ───────────────────────────────── * "동일 부팅 → vantage 정렬" 을 **가정**하고 배열 인덱스끼리 더했다. 그 가정은 세 곳에서 깨졌다: * * ① 격자를 트윈마다 자기 저널의 첫·마지막 이벤트로 만들었다. 저널 구간이 다르면 같은 인덱스가 * 서로 다른 시각이다. **부팅이 같아도** 어긋난다. * ② 한 vantage 를 못 재면 `continue` 로 건너뛰고 밀어 담았다. 그 순간부터 인덱스가 한 칸씩 밀린다. * ③ 길이를 `max` 로 잡아, 뒤쪽 인덱스는 남은 트윈만 더해졌다 — 공장 합계 곡선이 꼬리에서 * 아래로 꺾인다. 공장과 아무 상관 없는 이유로. * * 셋 다 오류를 내지 않았다. 그럴듯한 곡선이 나오고, 그 곡선으로 예측 정확도를 판정했다. * * ── 지금 ──────────────────────────────────────────────────────────────────── * 격자는 밖에서 하나를 만들어 모두에게 같은 것을 주고(`sharedGrid`), 못 잰 자리는 `null` 로 * 남긴다. 여기서는 **모든 트윈이 값을 가진 vantage 만** 더한다. 하나라도 비면 그 시각의 * 공장 총량은 알 수 없는 것이지, 그 트윈이 0 을 낸 것이 아니다. * * 뺀 개수는 돌려준다 — 화면이 "24점 중 18점" 이라고 말할 수 있어야 한다. */ export function aggregatePoints(per: (GapPoint | null)[][]): AggregateResult { if (!per.length) return { points: [], droppedVantages: 0 } const n = Math.max(0, ...per.map(pts => pts.length)) const out: GapPoint[] = [] let droppedVantages = 0 for (let i = 0; i < n; i++) { const slot = per.map(pts => pts[i]) if (slot.some(pt => !pt)) { droppedVantages++; continue } const pts = slot as GapPoint[] const sum = (f: (p: GapPoint) => number) => pts.reduce((a, p) => a + f(p), 0) const p50 = sum(p => p.p50) const min = sum(p => p.min) const max = sum(p => p.max) const actual = sum(p => p.actual) out.push({ t: pts[0].t, // 격자가 하나이므로 어느 트윈에서 읽어도 같다. p50, min, max, actual, error: actual - p50, inBand: actual >= min && actual <= max, naiveAbsError: sum(p => p.naiveAbsError), modelAbsError: sum(p => p.modelAbsError) }) } return { points: out, droppedVantages } } /** 안정성 축 + 편향 축 판정(관리도). errors = 실제−예측 시계열. */ export function computeSpc(errors: number[]): any { const n = errors.length if (n < 4) return { insufficient: true } const cl = errors.reduce((a, b) => a + b, 0) / n const mr: number[] = [] for (let i = 1; i < n; i++) mr.push(Math.abs(errors[i] - errors[i - 1])) const mrBar = mr.reduce((a, b) => a + b, 0) / mr.length const sigma = mrBar / 1.128 || Math.max(1e-6, Math.abs(cl) * 0.01) // d2(n=2)=1.128 const ucl = cl + 3 * sigma, lcl = cl - 3 * sigma // 축1 안정성 — 관리한계 이탈(shift) 또는 단조 추세(drift). const ooc = errors.map((e, i) => (e > ucl || e < lcl ? i : -1)).filter(i => i >= 0) let up = 1, down = 1, trend = false for (let i = 1; i < n; i++) { up = errors[i] > errors[i - 1] ? up + 1 : 1; down = errors[i] < errors[i - 1] ? down + 1 : 1; if (up >= 6 || down >= 6) trend = true } const instability = ooc.length ? 'shift' : trend ? 'drift' : null // 축2 편향 — 평균 오차가 0 에서 이탈. |CL| > 0.5σ 또는 추적신호(누적오차/평균절대오차) > 4. const mad = errors.reduce((s, e) => s + Math.abs(e), 0) / n || 1 const trackingSignal = errors.reduce((a, b) => a + b, 0) / mad const biased = Math.abs(cl) > 0.5 * sigma || Math.abs(trackingSignal) > 4 return { cl, ucl, lcl, sigma, n, ooc, instability, stable: !instability, biased, biasSign: cl >= 0 ? 'under' : 'over', trackingSignal } } /** * 집계 지표 — 정확도(1−WMAPE)·편향(MPE)·예측구간 적중·FVA(예측 가치). * * A percentage whose denominator is zero is **not measured**, and says so with `null` (2026-09-24). * When every actual in the window is 0 — the line stood still, or the metric was not observed — WMAPE * is undefined. It used to become 0, so the card read "100% accurate" beside "0 of 6 in band, mean * error −20". Likewise `fvaPct` when the naive error is 0: 0 read as "the forecast adds nothing". * The absolute measures (mean error, in-band count, fva) are real in that window and stay. The two * neighbours below already refuse on thin evidence (`computeSpc` · `learnedCalibration`). */ export function gapMetrics(pts: GapPoint[]): any { const sumAbsErr = pts.reduce((s, x) => s + Math.abs(x.error), 0) const sumActual = pts.reduce((s, x) => s + Math.abs(x.actual), 0) const accuracy = sumActual > 0 ? Math.max(0, Math.round((1 - sumAbsErr / sumActual) * 100)) : null const mpe = pts.length ? pts.reduce((s, x) => s + x.error, 0) / pts.length : 0 const coverage = pts.length ? pts.filter(x => x.inBand).length / pts.length : 0 // 예측 가치(FVA) — "현재값 그대로"(naive) 대비 오차 감소. fva=감소 총량, fvaPct=naive 오차를 몇 % 줄였나(가치의 크기). const sumNaive = pts.reduce((s, x) => s + x.naiveAbsError, 0) const sumModel = pts.reduce((s, x) => s + x.modelAbsError, 0) const fva = sumNaive - sumModel const fvaPct = sumNaive > 0 ? Math.round((1 - sumModel / sumNaive) * 100) : null return { accuracy, mpe, coverage, inBandCount: pts.filter(x => x.inBand).length, total: pts.length, fva, fvaPct } } /** 학습(자기보정) 예상 — 평균 오차(편향)를 걷어냈을 때 기대되는 정확도·커버리지. accBefore/covBefore 는 원본 지표에서. */ export function learnedCalibration(pts: GapPoint[], metrics: any): any { if (pts.length < 3) return null const errs = pts.map(p => p.error) const biasShift = errs.reduce((a, b) => a + b, 0) / errs.length // 학습된 편향(이만큼 예측을 올림/내림) const corrected = errs.map(e => e - biasShift) // 편향 제거 후 잔차 const absC = corrected.map(Math.abs).sort((a, b) => a - b) const halfWidth = absC[Math.floor(absC.length * 0.8)] ?? (absC[absC.length - 1] || 1) // 80% 커버리지 밴드 /* The bias is learnable with every actual at 0; the accuracy before and after is not (see gapMetrics). */ const sumActual = pts.reduce((s, p) => s + Math.abs(p.actual), 0) const accAfter = sumActual > 0 ? Math.max(0, Math.round((1 - corrected.reduce((s, e) => s + Math.abs(e), 0) / sumActual) * 100)) : null const covAfter = corrected.filter(e => Math.abs(e) <= halfWidth).length return { biasShift: Math.round(biasShift), halfWidth: Math.round(halfWidth), accBefore: metrics.accuracy, accAfter, covBefore: metrics.inBandCount, covAfter, n: pts.length } }