/* * OEE 이벤트 누적기 (순수·근사) — live-모드 OEE 입력 생산의 한 경로(face2-inbound-live §1.1, option ②). * * 문제: OEE 공식(computeOee)은 sim/live 공유되나, 입력 카운터(runMs/downMs/…)는 미러 State·이벤트에 없다 * (emitEquipment 는 status/location/motion 만 방출). → live 는 카운터를 별도 생산해야 한다. * 이 누적기: equipment.status 전이 스트림으로 무버별 시간-in-상태를 누적해 카운터를 근사 생산. * * 정확: downMs(고장 시간 = down 구간 합) · quality(good/scrap = quality.output 델타). 근사·한계: * - busyMs 를 runMs 로(setup 분리 불가 — 이벤트에 setup 없음) → performance 낙관·availability 는 setup 손실 미반영. * - quality.output 이 없으면(예: WMS/YMS) quality=1(품질 개념 없음). * 시간축 = Date.parse(eventTime)(ms) — 구간 차분은 sim/실시간 무관하게 정확(고정 오프셋 상쇄). */ /* OEE 공식은 계약에 있다 — 소비자가 셋이라(시뮬·미러·MES) 커널에 두면 밖에서 다시 쓰게 된다. */ import { computeOee, nonWorkingMsIn } from '@operato/ops-contract' import type { OeeCounters, WorkCalendarEntry } from '@operato/ops-contract' import type { CanonicalEnvelope, OeeMetrics } from '@operato/ops-contract' interface Acc { status?: string lastMs: number startMs: number busyMs: number downMs: number goodCount: number nonconformingCount: number } /** 누적기의 완전한 재개점 — 저장했다 되살릴 수 있어야 꼬리만 계산할 수 있다. */ export interface OeeCheckpoint { maxMs: number acc: ({ moverId: string } & Acc)[] } export class OeeAccumulator { private acc = new Map() private maxMs = 0 // 최신 이벤트 시각 — nowMs 기본값(현재 계측 시점) /* * ── 현장이 언제 도나 — **계획 조업 시간의 근거** (2026-09-07) ──────────────── * 이것이 없던 동안 계획 조업 시간이 「재기 시작한 뒤 흐른 시간 전부」였다. 주말도 야간도 휴일도 * 계획 조업으로 세었다. * * [산술] 8시간 × 주5일이면 168시간 중 40시간이 조업이다 — PBT 가 4.2배 크고 가동률이 그만큼 작다. * 그리고 그 수가 1로 잘려 있어서(오늘 걷었다) 그럴싸하게 보였다. 두 오류가 서로를 가렸다. * * plant 레인이 같은 자리를 먼저 겪고 실측을 냈다 — 3교대를 물려받으면 `availability 0.4583`, * 달력이 아예 없으면 **수를 안 낸다**. 트윈은 창 전체를 계획으로 쳐서 수를 냈다. * * ⚠ **선언이 없으면 채우지 않는다.** `nonWorkingMsIn` 이 그때 `undefined` 를 내고, 그 값을 0 으로 * 바꾸지 않는다 — 0 은 「달력을 봤고 쉬는 시간이 없었다」는 주장이다. 지금은 그러면 PBT 가 창 전체가 * 되어 예전과 같은 수가 나온다. **그것을 「안 낸다」로 바꿀지는 아직 결정되지 않았다** — 거동 변화가 * 크고 화면이 비는 자리라 사용자 판단을 기다린다(성숙도에 미달로 세우는 짝과 함께 가야 한다). */ private siteCalendar?: readonly WorkCalendarEntry[] private calendarByResource = new Map() private utcOffsetMinutes?: number /** * 모델에서 조업 달력을 읽어 얹는다 — 모델을 들고 있는 쪽(`TwinEngine`)이 부른다. * * ── 우선순위가 여기 있는 이유 ─────────────────────────────────────────────── * ``` * 설비의 달력 이 설비의 가동 계획 — 선언했으면 이것이 이긴다 (24시간 도는 로) * 현장의 달력 이 현장의 조업일 * 없음 제약 없음 — 계획 조업 시간이 창 전체가 된다 * ``` * 계획 조업 시간을 내는 자리가 여기라 그 규칙도 여기 둔다. 부르는 쪽마다 고르게 하면 라이브와 * 히스토리가 다른 답을 낸다. * * ⚠ **설비의 달력은 사람의 근무조가 아니다.** 씨앗이 사람의 근무조를 설비 달력에 복사한 일이 있었고, * 그때 아무도 근무하지 않는 시간이 근무 시간으로 기록됐다. 사람 쪽은 `persons[].workCalendar` 다. * * 세우는 자리에서 한 번만 주지 않고 따로 부르는 이유는 모델이 다시 실릴 수 있기 때문이다 * (재동기화 · 이관). 그때 달력만 갈아 끼우면 누적된 계측을 버리지 않는다. */ setWorkCalendar(model?: { workCalendar?: readonly WorkCalendarEntry[]; equipment?: readonly any[]; utcOffsetMinutes?: number }): void { this.siteCalendar = model?.workCalendar?.length ? model.workCalendar : undefined this.utcOffsetMinutes = model?.utcOffsetMinutes this.calendarByResource.clear() for (const e of model?.equipment ?? []) { const cal = e?.workCalendar if (e?.id && Array.isArray(cal) && cal.length) this.calendarByResource.set(String(e.id), cal) } } /** * 이 설비의 계획 가동 달력 — 자기 것이 있으면 그것, 없으면 현장의 것. * * 인자 이름이 `moverId` 인 것은 **봉투의 옛 세대 필드 이름**이라 그렇다(§`apply`). 새로 만드는 * 이름에는 그 낱말을 쓰지 않는다 — 어휘 가드가 그것을 잡았고, 맞게 잡았다. */ private calendarFor(moverId: string): readonly WorkCalendarEntry[] | undefined { return this.calendarByResource.get(moverId) ?? this.siteCalendar } private ensure(moverId: string, t: number): Acc { let a = this.acc.get(moverId) if (!a) { a = { lastMs: t, startMs: t, busyMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0 } this.acc.set(moverId, a) } return a } /** * **재개점** — 이 누적기의 상태 전부(이어 계산의 씨앗). * * ── 왜 필요한가 (2026-08-18) ──────────────────────────────────────────────── * 가동 이력은 원 시스템이 누적을 주지 않아 **우리가 이벤트를 적분해** 만든다. 그래서 저널을 꼬리만 * 계산하면 이 누적기는 꼬리분만 갖게 되고, 화면의 가용률·수율이 알리지 않고 작아진다(오류는 나지 않는다). * 재개점을 함께 저장·복원해야 「이어 계산한 값」이 「0부터 계산한 값」과 같아진다. */ serialize(): OeeCheckpoint { return { maxMs: this.maxMs, acc: [...this.acc.entries()].map(([moverId, a]) => ({ moverId, ...a })) } } /** 재개점에서 되세운다 — 모르는 것은 지어내지 않는다(없으면 빈 누적기다). */ restore(cp: OeeCheckpoint | undefined | null): void { this.maxMs = cp?.maxMs ?? 0 this.acc = new Map((cp?.acc ?? []).map(({ moverId, ...a }) => [moverId, { ...a }])) } /** equipment.status(시간-in-상태) + quality.output(양품/불량) 소비. 다른 eventType 무시. */ apply(env: CanonicalEnvelope): void { const t = Date.parse(env.eventTime) if (t > this.maxMs) this.maxMs = t if (env.eventType === 'quality.output') { const q = env.data as { moverId?: string; goodCount?: number; nonconformingCount?: number } if (!q?.moverId) return const a = this.ensure(q.moverId, t) a.goodCount = q.goodCount ?? a.goodCount // 누적값 최신화(good/scrap 은 무버 누적) a.nonconformingCount = q.nonconformingCount ?? a.nonconformingCount return } if (env.eventType !== 'equipment.status') return const d = env.data as { moverId?: string; status?: string } if (!d?.moverId) return const a = this.ensure(d.moverId, t) const dt = Math.max(0, t - a.lastMs) if (a.status === 'busy') a.busyMs += dt else if (a.status === 'down') a.downMs += dt a.status = d.status a.lastMs = t } /** 무버 OEE 카운터 — 마지막 상태 구간을 nowMs(기본=최신 이벤트 시각)까지 flush. busy→run 근사, setup=0. */ counters(moverId: string, nowMs: number = this.maxMs): OeeCounters { const a = this.acc.get(moverId) /* * **이 설비의 이벤트를 하나도 못 봤다** — 계측 창이 없다는 뜻이고, 그것을 `0` 으로 메우면 * "1970년부터 재고 있었다" 가 된다. 시각이 밀리초 epoch 이므로 그 값이 곧 지금 시각이 되어, * 화면에 `idleMs 1767226603000`(약 56,000년) 유휴가 섰다. 창을 **말하지 않는다** — 공식이 * 없음을 없음으로 다룬다(§computeOee). */ if (!a) return { runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, nonconformingCount: 0 } const dt = Math.max(0, nowMs - a.lastMs) let busyMs = a.busyMs let downMs = a.downMs if (a.status === 'busy') busyMs += dt else if (a.status === 'down') downMs += dt /* * 계획 정지 — 계약이 이 칸에 그 뜻을 적어 뒀다(점심 · 예방보전 · 교대). 휴일과 주말이 같은 * 갈래다. `metricsSinceMs` 를 당겨서 같은 효과를 내지 않는다: 그 칸은 「언제부터 쟀나」라는 * 사실이고, 파생값을 얹으면 실제보다 늦게 재기 시작했다고 말하는 것이 된다. */ const holdMs = nonWorkingMsIn(this.calendarFor(moverId), a.startMs, nowMs, this.utcOffsetMinutes) return { runMs: busyMs, setupMs: 0, downMs, goodCount: a.goodCount, nonconformingCount: a.nonconformingCount, metricsSinceMs: a.startMs, ...(holdMs !== undefined ? { holdMs } : {}) } } /** 공유 공식으로 근사 OEE 산출 — 정확한 OEE 는 실 텔레메트리(option ①). nowMs 기본=최신 이벤트 시각. */ oee(moverId: string, nowMs: number = this.maxMs): OeeMetrics { return computeOee(this.counters(moverId, nowMs), nowMs) } } /** * live State 를 OEE 로 보강 — projector 미러 State(관측)의 무버에 누적기 OEE(계산)를 채운다. * live-모드 publishEntityData 가 buildEntityDeltas 전에 적용 → equipment data(tag) payload 에 oee 포함 * (sim 은 커널이 채움 — 이로써 live 도 동일 payload 형상). 순수(입력 불변, 얕은 복사). */ export function withLiveOee(state: S, acc: OeeAccumulator, nowMs?: number): S { if (!state?.equipment) return state return { ...state, equipment: state.equipment.map(m => ({ ...m, oee: acc.oee(m.id, nowMs) })) } }