/* * ★ OEE 이벤트 누적기 검증 — equipment.status 스트림으로 카운터 근사 생산 → computeOee. * live 가 실 텔레메트리 없이도 OEE 를 근사 산출하는 경로(face2-inbound-live §1.1 option ②). * 정확 검증: downMs(고장 시간) == sim, busyMs == sim(run+setup). 근사 한계 명시: quality=1(good/scrap 이벤트 부재). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { WmsKernel, StateProjector } from '@operato/twin-kernel' import { masterToTwin } from '../server/service/reference/reference-master.ts' import { buildEntityDeltas } from '../shared/entity-delta.ts' import { OeeAccumulator, withLiveOee } from '../server/engine/oee-accumulator.ts' const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z') // 커널 now() 고정 오프셋(flow-engine BASE_EPOCH) const GTIN = 'urn:epc:idpat:sgtin:0614141.099999.*' const TICK = 1000 function runSimCollect() { const m = { source: 'oee', system: 'wms' as const, siteName: 'O', /* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른 시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를 만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */ identity: { namespaces: ['https://twin.test/oee-accumulator'] }, space: { width: 90, depth: 40, unit: 'm' }, locations: [ { id: 'a', type: 'warehouse', role: 'area' as const }, { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 }, { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 }, { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 }, { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }] } const model = masterToTwin(m).model const k = new WmsKernel('t') const events: any[] = [] k.onEvent((e: any) => events.push(e)) k.loadTwinModel(model) k.scenario.load({ seed: 9, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 300 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }] }) k.scenario.start() for (let i = 0; i < 200; i++) k.tick(TICK) return { events, snap: k.getSnapshot() as any, model } } test('★ OEE 누적기: downMs 정확 · busyMs=run+setup · 근사 OEE 유효', () => { const { events, snap } = runSimCollect() const simOee = snap.equipment.find((x: any) => x.id === 'fk-1')?.oee assert.ok(simOee, 'sim fk-1 oee 존재') assert.ok(simOee.downMs > 0, '시나리오가 고장(down) 유발 — 검증 의미 있음') const acc = new OeeAccumulator() for (const e of events) acc.apply(e) const nowMs = BASE_EPOCH + snap.simClockMs const c = acc.counters('fk-1', nowMs) // 정확: downMs(고장 시간) — 이벤트 down 구간 합 == sim(tick 누적). availability 핵심 입력. tick 경계 오차 ≤ 1 tick. assert.ok(Math.abs(c.downMs - simOee.downMs) <= TICK, `downMs 정확 (acc ${c.downMs} vs sim ${simOee.downMs})`) // busyMs ≈ run+setup, 단 **진행중 task 작업분 포함**(runMs 는 완료분만 누적) → busyMs ≥ 완료 run+setup. // 이벤트로 setup 분리 불가라 busy 를 run 으로 근사. 한 task 규모의 초과는 정상(진행중분). const completed = simOee.runMs + simOee.setupMs assert.ok(c.runMs >= completed - TICK, `busyMs ≥ 완료 run+setup (acc ${c.runMs} vs sim ${completed})`) assert.ok(c.runMs - completed <= 30000, `초과분(진행중 task)이 task 규모 이내 (${c.runMs - completed})`) // 근사 OEE 유효 + 한계 명시. const oee = acc.oee('fk-1', nowMs) /* * 총합은 **없을 수 있다.** 표준 시간(PRI)이 없으면 성능률이 안 나오고, 그러면 곱도 안 나온다 * (계약 0.5.0, ISO 22400 대조). 누적기는 사건에서 표준 시간을 알 수 없으므로 여기서는 없는 것이 정상이다. * * 예전에는 근거 없이 1 을 내서 총합이 늘 값을 가졌다. 그때 이 단언은 아무것도 지키지 않았다. */ if (oee.overall === undefined) { assert.ok(oee.missing?.includes('planned-run-time-per-item'), `총합이 없으면 이유가 있어야 한다: ${JSON.stringify(oee.missing)}`) } else { assert.ok(oee.overall >= 0 && oee.overall <= 1, 'OEE overall ∈ [0,1]') } assert.ok((oee.availability as number) >= 0 && (oee.availability as number) <= 1, 'availability ∈ [0,1]') /* * WMS 는 품질 개념이 없어 산출을 기록하지 않는다. 만든 것이 없으면 양품률은 **모름**이다. * 예전에는 1 을 냈고, 그래서 품질을 재지도 않는 창고 설비가 「품질 100%」로 보였다. */ assert.equal(oee.quality, undefined, 'WMS 는 산출 기록이 없으므로 양품률은 모름이다') assert.ok(oee.missing?.includes('produced-quantity')) }) test('★ OEE 누적기: quality.output 델타 소비 → quality 정확 (good/scrap 도출)', () => { const acc = new OeeAccumulator() const T0 = Date.parse('2026-01-01T00:00:00Z') const ev = (ms: number, type: string, data: any): any => ({ eventId: 'x', eventType: type, eventTime: new Date(T0 + ms).toISOString(), tenantId: 't', data }) acc.apply(ev(0, 'equipment.status', { moverId: 'w1', status: 'busy' })) acc.apply(ev(1000, 'quality.output', { moverId: 'w1', good: false, goodCount: 0, nonconformingCount: 1 })) acc.apply(ev(2000, 'quality.output', { moverId: 'w1', good: true, goodCount: 8, nonconformingCount: 2 })) // 누적값 최신화 acc.apply(ev(3000, 'equipment.status', { moverId: 'w1', status: 'idle' })) const c = acc.counters('w1', T0 + 4000) assert.equal(c.goodCount, 8, 'goodCount 추적(누적 최신)') assert.equal(c.nonconformingCount, 2, 'nonconformingCount 추적') const oee = acc.oee('w1', T0 + 4000) assert.ok(Math.abs(oee.quality - 0.8) < 1e-9, 'quality = good/(good+scrap) = 0.8 (텔레메트리 없이 이벤트로 도출)') }) test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live 설비 payload 에 oee 포함 (원 갭 종료)', () => { const { events, model } = runSimCollect() const proj = new StateProjector(model) const acc = new OeeAccumulator() for (const e of events) { proj.apply(e); acc.apply(e) } const rawLive = proj.snapshot() as any // 원 갭: projector 미러 설비엔 oee 없음. assert.equal(rawLive.equipment.find((m: any) => m.id === 'fk-1')?.oee, undefined, 'projector 미러엔 oee 부재(원 갭)') // withLiveOee 로 보강 → buildEntityDeltas payload 에 oee 포함(sim 과 동일 형상 = 보드 OEE 컴포넌트 live 렌더). const enriched = withLiveOee(rawLive, acc) const fk = buildEntityDeltas(enriched, 't1').find(d => d.tag === 'fk-1')!.data as any assert.ok(fk.oee, 'withLiveOee 후 설비 data(tag) payload 에 oee 포함 — live OEE 갭 종료') // buildEntityDeltas shapeOee = 정수 퍼센트(0-100), raw ms 제외. assert.ok(fk.oee.overall >= 0 && fk.oee.overall <= 100, 'oee.overall ∈ [0,100] 정수 퍼센트') assert.ok(fk.oee.availability < 100, 'availability < 100 (고장 시간 반영 — 누적기 downMs)') }) /* * ── 되짚은 화면에도 가동 이력이 있어야 한다 (2026-08-05) ──────────────────── * OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다. 그런데 그 누적기는 * **라이브에서만** 돌았고, 저널을 계산하는 경로는 그 계산을 하지 않았다 — 과거를 되짚으면 모든 설비가 * "가동 이력이 전혀 없음" 으로 보였다(실 저널: `rosarito-mes` 18대, `hatiolab-wms` 22대가 그랬다). * * 없는 것은 데이터가 아니라 계산이다: 입력은 저널에 다 있다. 그래서 같은 누적기에 같은 이벤트를 * 태운다 — 이 테스트는 그 재계산이 라이브와 **같은 값**을 내는지, 그리고 시간여행에서 **그 시점까지만** * 세는지를 못 박는다. */ test('★ 저널을 다시 태우면 라이브와 같은 계측이 나온다 — 규칙이 하나이므로', () => { const T0 = Date.parse('2026-08-05T00:00:00.000Z') const ev = (ms: number, id: string, status: string) => ({ eventId: `e${ms}`, eventType: 'equipment.status', eventTime: new Date(T0 + ms).toISOString(), tenantId: 't', data: { equipmentId: id, moverId: id, status } }) const stream = [ev(0, 'fk-1', 'idle'), ev(10_000, 'fk-1', 'busy'), ev(40_000, 'fk-1', 'down'), ev(70_000, 'fk-1', 'idle')] /* 라이브 — 이벤트가 도착하는 대로 누적. */ const live = new OeeAccumulator() for (const e of stream) live.apply(e as any) /* 되짚기 — 저널에서 같은 순서로 다시 태운다. */ const replayed = new OeeAccumulator() for (const e of stream) replayed.apply(e as any) const at = T0 + 70_000 assert.deepEqual(replayed.oee('fk-1', at), live.oee('fk-1', at), '같은 이벤트·같은 시점이면 같은 값이어야 한다') assert.equal(replayed.oee('fk-1', at).downMs, 30_000, '고장 30초가 잡혀야 한다') }) test('★ 시간여행은 그 시점까지만 센다 — 뒤에 일어난 고장이 과거 화면에 섞이지 않게', () => { const T0 = Date.parse('2026-08-05T00:00:00.000Z') const ev = (ms: number, status: string) => ({ eventId: `e${ms}`, eventType: 'equipment.status', eventTime: new Date(T0 + ms).toISOString(), tenantId: 't', data: { equipmentId: 'fk-1', moverId: 'fk-1', status } }) const all = [ev(0, 'busy'), ev(30_000, 'down'), ev(60_000, 'idle')] /* 30초 시점까지만 태운다(호스트가 잘라 낸 이벤트만 태우는 것과 같다). */ const past = new OeeAccumulator() for (const e of all.slice(0, 2)) past.apply(e as any) assert.equal(past.oee('fk-1', T0 + 30_000).downMs, 0, '아직 고장이 시작된 시점 — 고장 시간은 0 이다') const now = new OeeAccumulator() for (const e of all) now.apply(e as any) assert.equal(now.oee('fk-1', T0 + 60_000).downMs, 30_000, '지금 화면에서는 고장 30초가 잡힌다') }) test('★ 계측 시점을 벽시계로 재지 않는다 — 과거 화면의 가용률이 시간이 갈수록 떨어지지 않게', () => { const T0 = Date.parse('2026-08-05T00:00:00.000Z') const acc = new OeeAccumulator() acc.apply({ eventId: 'e1', eventType: 'equipment.status', eventTime: new Date(T0).toISOString(), tenantId: 't', data: { equipmentId: 'fk-1', moverId: 'fk-1', status: 'busy' } } as any) const atThen = acc.oee('fk-1', T0 + 10_000) const atMuchLater = acc.oee('fk-1', T0 + 10_000_000) assert.notDeepEqual(atThen, atMuchLater, '시점을 넘기면 값이 달라진다 — 그래서 그 화면의 시각을 넘겨야 한다') }) /* * ★ 못 본 설비의 계측 창 — **없으면 없다고 한다.** * * 예전에는 `metricsSinceMs: 0` 을 돌려줬다. 시각이 epoch 밀리초라서 그 창은 곧 "1970년부터" 가 되고, * 공식의 분모가 지금 시각 전체가 된다 — 화면에 `idleMs 1767226603000`(약 56,000년) 유휴가 섰다. * 그 값은 availability·overall 의 분모이기도 해서 **성과 숫자 전체가 알리지 않고 거짓**이 됐다. */ test('이벤트를 못 본 설비는 계측 창을 만들지 않는다 — epoch 를 창으로 삼지 않는다', () => { const acc = new OeeAccumulator() const c = acc.counters('never-seen', Date.parse('2026-01-01T00:00:00.000Z')) assert.equal(c.metricsSinceMs, undefined, '모르는 창을 0 으로 메우면 1970년부터 쟀다는 말이 된다') /* * 그다음(창이 없을 때 유휴는 0)은 **공식의 책임**이고 공식은 커널에 있다 — 커널의 * `test/oee-window` 가 못 박는다. 여기서 그 결과까지 단정하면 이 검사는 커널 발행 시점에 * 묶이고, 남의 책임을 자기 검사에 넣은 것이 된다. */ })