@operato/twin-kernel 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from '@operato/ops-contract';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from '@operato/ops-contract';
2
2
  import type { VocabularyElement } from '@operato/ops-contract';
3
3
  import type { ReducerCheckpoint } from './observed-reducer.ts';
4
4
  import type { EpcisEvent, BizTransactionElement } from '@operato/ops-contract';
@@ -411,22 +411,6 @@ nowIso?: ISOTime,
411
411
  * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
412
412
  */
413
413
  thresholds?: AttentionThresholds): Attention[];
414
- /** OEE 계측 카운터 — sim 은 tick 으로 누적, live 는 실 텔레메트리 또는 이벤트 누적기가 채운다(face2-inbound-live §1.1). */
415
- export interface OeeCounters {
416
- runMs: number;
417
- setupMs: number;
418
- downMs: number;
419
- goodCount: number;
420
- scrapCount: number;
421
- holdMs?: number;
422
- metricsSinceMs?: number;
423
- }
424
- /**
425
- * OEE 공식(순수) — 누적 카운터 + 현재 시각 → OEE. sim(oeeOf)과 live 가 **공유하는 계산 층**(face2-inbound-live §1.1).
426
- * ⚠ attentions 와 달리: 입력(카운터)이 관측 State·이벤트에 없다 → live 는 카운터를 텔레메트리(정확) 또는
427
- * 이벤트 누적기(근사)로 별도 공급해야 한다. 공식만 공유되고 입력원은 live 데이터-생산 결정.
428
- */
429
- export declare function computeOee(c: OeeCounters, nowMs: number): OeeMetrics;
430
414
  /**
431
415
  * 물품 저장소 — **맵과 「자리별 색인」을 함께 든다.**
432
416
  *
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit } from '@operato/ops-contract';
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit, computeOee } from '@operato/ops-contract';
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  /* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
15
15
  import { resolveSubject } from '@operato/ops-contract';
@@ -332,32 +332,6 @@ thresholds) {
332
332
  a.state = 'acknowledged';
333
333
  return out;
334
334
  }
335
- /**
336
- * OEE 공식(순수) — 누적 카운터 + 현재 시각 → OEE. sim(oeeOf)과 live 가 **공유하는 계산 층**(face2-inbound-live §1.1).
337
- * ⚠ attentions 와 달리: 입력(카운터)이 관측 State·이벤트에 없다 → live 는 카운터를 텔레메트리(정확) 또는
338
- * 이벤트 누적기(근사)로 별도 공급해야 한다. 공식만 공유되고 입력원은 live 데이터-생산 결정.
339
- */
340
- export function computeOee(c, nowMs) {
341
- /*
342
- * **계측 창을 모르면 잰 시간이 없다** — `?? 0` 으로 메우면 "1970년부터 재고 있었다" 가 된다.
343
- *
344
- * 그 결과가 화면까지 갔다: 갓 뜬 트윈의 절단기가 `idleMs 1767226603000`(약 56,000년) 유휴로 섰다.
345
- * 시각을 소요시간 자리에 앉힌 것이고, 같은 값이 availability·overall 의 분모라 **성과 숫자 전체가
346
- * 조용히 거짓**이 된다(가동률 100%, 성능 0%).
347
- *
348
- * 모르면 0 이 아니라 **없음**이다: 잰 구간이 없으면 유휴도 없다(아직 아무것도 재지 않았다).
349
- */
350
- const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
351
- const uptime = Math.max(0, planned - c.setupMs - c.downMs); // 가용시간(셋업·고장 제외)
352
- const availability = planned > 0 ? uptime / planned : 1;
353
- const performance = uptime > 0 ? Math.min(1, c.runMs / uptime) : (c.runMs > 0 ? 1 : 0);
354
- const totalQ = c.goodCount + c.scrapCount;
355
- const quality = totalQ > 0 ? c.goodCount / totalQ : 1;
356
- return {
357
- availability, performance, quality, overall: availability * performance * quality,
358
- runMs: c.runMs, setupMs: c.setupMs, downMs: c.downMs, idleMs: Math.max(0, uptime - c.runMs), goodCount: c.goodCount, scrapCount: c.scrapCount
359
- };
360
- }
361
335
  /**
362
336
  * 에너지 능력이 선언한 상태 필드를 스냅샷에 싣는다 — **온 것만.**
363
337
  *
@@ -1075,7 +1049,7 @@ export class FlowEngine {
1075
1049
  * 한계가 있다 — busy 를 가동으로 근사하고 **셋업을 분리하지 못해 0** 으로 둔다. 여기서는 받은
1076
1050
  * 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
1077
1051
  */
1078
- /* `idleMs` 까지 읽는다 — 그것이 있어야 **잰 구간**을 되세울 수 있다(계획 시간 = 가동+준비+고장+유휴). */
1052
+ /* `delayMs` 까지 읽는다 — 그것이 있어야 **잰 구간**을 되세울 수 있다(계획 조업 = 생산+준비+고장+대기). */
1079
1053
  const oee = m.oee;
1080
1054
  /* **마스터가 말한 것을 관측이 지우지 않는다.** 예전에는 이 자리에서 통째로 새 객체를 만들어
1081
1055
  * 교대 캘린더·속성·유효 기간·계획정지를 **전부 버렸다.** 그 결과 라이브(관측) 커널은
@@ -1105,7 +1079,7 @@ export class FlowEngine {
1105
1079
  * 뒤로 물려 창을 다시 세운다. 지표가 없으면 창도 없다(그때는 지금부터 새로 잰다).
1106
1080
  */
1107
1081
  ...(oee
1108
- ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.idleMs ?? 0)) }
1082
+ ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.delayMs ?? 0)) }
1109
1083
  : {}),
1110
1084
  /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
1111
1085
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
@@ -1,4 +1,4 @@
1
- import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from '@operato/ops-contract';
1
+ import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift, DispositionFact } from '@operato/ops-contract';
2
2
  import { type VocabularyElement } from '@operato/ops-contract';
3
3
  interface ProjItem {
4
4
  epc: string;
@@ -8,7 +8,10 @@ interface ProjItem {
8
8
  gtinKey?: string;
9
9
  lot?: string;
10
10
  location: string;
11
+ /** EPCIS 의 처지(`urn:epcglobal:cbv:disp:…`). 아래 `nonconformance` 와 다른 뜻이다. */
11
12
  disposition?: string;
13
+ /** ISA-95 의 부적합 처분 — 사람이 정한 결정. 로트 전체의 사실이라 부분마다 같은 값이 앉는다. */
14
+ nonconformance?: DispositionFact;
12
15
  parent?: string;
13
16
  qty?: number;
14
17
  uom?: string;
@@ -152,6 +155,23 @@ export interface ReducerCheckpoint {
152
155
  firstAtMs?: number;
153
156
  lastAtMs?: number;
154
157
  }[];
158
+ /**
159
+ * 설비마다 쌓은 OEE 계수기 — 산출은 `quality`, 시간은 `equipment-period` 에서 온다.
160
+ *
161
+ * 원천이 되풀어 주지 않는 축이라 저장해야 한다. 저장하지 않으면 재기동 뒤에 어제까지 쌓은 시간이
162
+ * 사라지고 가동률이 그 순간부터 다시 세어진다.
163
+ */
164
+ oeeCounters?: {
165
+ id: string;
166
+ metricsSinceMs?: number;
167
+ metricsUntilMs?: number;
168
+ runMs: number;
169
+ setupMs: number;
170
+ downMs: number;
171
+ holdMs: number;
172
+ goodCount: number;
173
+ scrapCount: number;
174
+ }[];
155
175
  /** 자리별 · 속성별 마지막 관측 — 이어 계산가 이 축을 0부터 다시 만들지 않게. */
156
176
  observations?: {
157
177
  id: string;
@@ -237,6 +257,13 @@ export declare class ObservedReducer {
237
257
  private corrections;
238
258
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
239
259
  private unhandled;
260
+ /**
261
+ * **설비마다 쌓는 OEE 계수기** — 상태가 아니라 누적이다.
262
+ *
263
+ * 원천이 되풀어 주지 않는 축이라 저장본에 함께 담는다(§`serialize`). 담지 않으면 재기동 뒤에 어제까지
264
+ * 쌓은 시간이 사라지고, 가동률이 그 순간부터 다시 세어진다.
265
+ */
266
+ private oeeCounters;
240
267
  /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
241
268
  private testSpecs;
242
269
  /**
@@ -341,6 +368,19 @@ export declare class ObservedReducer {
341
368
  * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
342
369
  * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
343
370
  */
371
+ /**
372
+ * 그 설비의 OEE — **원천이 말해 준 구간까지만 센다.**
373
+ *
374
+ * 조업 시간의 끝을 시계에서 가져오지 않는다. 시계를 쓰면 원천이 말을 멈춘 동안에도 조업 시간만 자라
375
+ * 가동률이 계속 떨어지는데, 그것은 설비의 사실이 아니라 연결의 사실이다. 미러가 아는 것은
376
+ * **구간으로 들은 시간**뿐이고, 그 밖의 시간에 그 설비가 무엇을 했는지는 모른다.
377
+ *
378
+ * 그래서 창은 `[가장 이른 from, 가장 늦은 to]` 다. 구간을 하나도 못 들었으면 조업 시간이 없고,
379
+ * `computeOee` 가 가동률 대신 `missing` 을 낸다.
380
+ *
381
+ * 잰 것이 없으면 아무것도 내지 않는다. 빈 계수기로 0% 를 그리면 「재지 않았다」가 「안 돌았다」가 된다.
382
+ */
383
+ private oeePart;
344
384
  private noteUnhandled;
345
385
  /**
346
386
  * 팔레트 물품에 **담고 있는 것**을 채운다 — 이미 아는 값을 덮지 않는다.
@@ -19,6 +19,7 @@
19
19
  * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
+ import { computeOee } from '@operato/ops-contract';
22
23
  import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from '@operato/ops-contract';
23
24
  import { ILMD_ATTR, parseEpc } from '@operato/ops-contract';
24
25
  import { expiryFromAttributes, lotFromAttributes } from '@operato/ops-contract';
@@ -41,6 +42,9 @@ function effectiveOf(r) {
41
42
  ...(r?.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
42
43
  };
43
44
  }
45
+ function emptyOeeCounters() {
46
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
47
+ }
44
48
  export class ObservedReducer {
45
49
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
46
50
  master = new Map();
@@ -89,6 +93,13 @@ export class ObservedReducer {
89
93
  corrections = [];
90
94
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
91
95
  unhandled = new Map();
96
+ /**
97
+ * **설비마다 쌓는 OEE 계수기** — 상태가 아니라 누적이다.
98
+ *
99
+ * 원천이 되풀어 주지 않는 축이라 저장본에 함께 담는다(§`serialize`). 담지 않으면 재기동 뒤에 어제까지
100
+ * 쌓은 시간이 사라지고, 가동률이 그 순간부터 다시 세어진다.
101
+ */
102
+ oeeCounters = new Map();
92
103
  /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
93
104
  testSpecs = new Map();
94
105
  /**
@@ -447,6 +458,96 @@ export class ObservedReducer {
447
458
  this.touchLocation(d.location);
448
459
  break;
449
460
  }
461
+ case OP_EVENT.quality: {
462
+ /*
463
+ * **산출 누적** — 설비가 지금까지 몇 개를 냈나. OEE 의 양품률이 이 값에서만 나온다.
464
+ *
465
+ * 예전에는 이 사건을 관측이 읽지 않았다. 유입 문도 있고 저널에도 남는데 **상태에 앉는 자리가
466
+ * 없어서**, 미러 트윈의 양품률이 영영 비어 있었다. 시뮬레이션 커널만 자기 계수기를 채웠다.
467
+ *
468
+ * 값은 **절대 누적**이다(계약이 그렇게 정의한다). 그래서 더하지 않고 그대로 놓는다 — 더하면
469
+ * 재전송 한 번에 두 배가 된다.
470
+ */
471
+ const d = e.data;
472
+ if (!d?.moverId)
473
+ break;
474
+ if (this.stale(`quality:${d.moverId}`, e))
475
+ return;
476
+ const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
477
+ c.goodCount = d.goodCount;
478
+ c.scrapCount = d.scrapCount;
479
+ this.oeeCounters.set(d.moverId, c);
480
+ this.markHeard(d.moverId, e);
481
+ break;
482
+ }
483
+ case OP_EVENT.equipmentPeriod: {
484
+ /*
485
+ * **마감된 상태 구간** — 그 설비가 그 구간 동안 그 상태였다. 시간을 종류별로 쌓는다.
486
+ *
487
+ * `stale` 을 지나지 않는다. 구간 하나하나가 **자기 사실**이지 상태를 덮는 것이 아니어서,
488
+ * 시각으로 앞뒤를 가리면 늦게 적힌 어제의 정지가 버려진다. 되돌아가 적는 것이 이 어휘의 존재
489
+ * 이유다. 같은 구간이 두 번 오는 것은 유입 경계가 거른다.
490
+ *
491
+ * **지금 상태를 바꾸지 않는다.** 어제의 구간이 오늘의 상태가 되면 안 된다.
492
+ */
493
+ const d = e.data;
494
+ if (!d?.moverId || !d?.from || !d?.to)
495
+ break;
496
+ const from = Date.parse(d.from);
497
+ const to = Date.parse(d.to);
498
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from)
499
+ break;
500
+ const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
501
+ const ms = to - from;
502
+ /* 어디에도 넣을 수 없는 상태는 세지 않는다 — 아무 칸에나 넣으면 가동률이 조용히 틀린다. */
503
+ if (d.status === 'busy')
504
+ c.runMs += ms;
505
+ else if (d.status === 'setup')
506
+ c.setupMs += ms;
507
+ else if (d.status === 'down')
508
+ c.downMs += ms;
509
+ else if (d.status === 'planned-stop')
510
+ c.holdMs += ms;
511
+ else if (d.status !== 'idle')
512
+ break;
513
+ /* 대기(`idle`)는 따로 쌓지 않는다 — 조업 시간에서 나머지로 나온다(ISO 22400 의 ADOT). */
514
+ c.metricsSinceMs = c.metricsSinceMs === undefined ? from : Math.min(c.metricsSinceMs, from);
515
+ c.metricsUntilMs = c.metricsUntilMs === undefined ? to : Math.max(c.metricsUntilMs, to);
516
+ this.oeeCounters.set(d.moverId, c);
517
+ this.markHeard(d.moverId, e);
518
+ break;
519
+ }
520
+ case OP_EVENT.disposition: {
521
+ /*
522
+ * **부적합 처분** — 이 로트를 어떻게 하기로 정했나. 대상을 가리켜 들어오고 그 개체 안에 앉는다
523
+ * (§`OP_EVENT.test` 와 같은 좁힘).
524
+ *
525
+ * **물품을 지어내지 않는다.** 모르는 대상의 처분은 `unhandled` 로 세어서 낸다 — 없는 재고를
526
+ * 만드는 것은 사실을 잃는 것보다 나쁘다.
527
+ */
528
+ const d = e.data;
529
+ if (!d?.subjectId || !d?.decision)
530
+ break;
531
+ if (this.stale(`disposition:${d.subjectId}`, e))
532
+ return;
533
+ /*
534
+ * **로트를 이름으로 찾는다.** 상태의 열쇠는 로트 하나가 아니다 — 같은 로트가 두 자리에 나뉘어
535
+ * 놓이면 부분마다 한 줄이고(§`ItemState` 의 `MaterialSubLot`), 처분은 **로트 전체의 사실**이다.
536
+ * 그래서 그 이름을 가진 부분 모두에 앉힌다.
537
+ *
538
+ * 부분마다 같은 사실이 앉으므로 **부분을 가로질러 수량을 합하면 안 된다.** 자리마다 다르게
539
+ * 처분하는 일이 생기면 그것은 이 사실이 아니라 자리를 가리키는 다른 사실이다.
540
+ */
541
+ const parts = [...this.items.values()].filter(i => i.epc === d.subjectId);
542
+ if (!parts.length) {
543
+ this.noteUnhandled(e, `${OP_EVENT.disposition}:unknown-subject`);
544
+ break;
545
+ }
546
+ /* `disposition` 은 EPCIS 의 이름이라 쓸 수 없다 — 같은 낱말을 두 표준이 다른 뜻으로 쓴다. */
547
+ for (const part of parts)
548
+ part.nonconformance = { ...d };
549
+ break;
550
+ }
450
551
  case OP_EVENT.observation: {
451
552
  /*
452
553
  * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
@@ -594,6 +695,24 @@ export class ObservedReducer {
594
695
  * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
595
696
  * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
596
697
  */
698
+ /**
699
+ * 그 설비의 OEE — **원천이 말해 준 구간까지만 센다.**
700
+ *
701
+ * 조업 시간의 끝을 시계에서 가져오지 않는다. 시계를 쓰면 원천이 말을 멈춘 동안에도 조업 시간만 자라
702
+ * 가동률이 계속 떨어지는데, 그것은 설비의 사실이 아니라 연결의 사실이다. 미러가 아는 것은
703
+ * **구간으로 들은 시간**뿐이고, 그 밖의 시간에 그 설비가 무엇을 했는지는 모른다.
704
+ *
705
+ * 그래서 창은 `[가장 이른 from, 가장 늦은 to]` 다. 구간을 하나도 못 들었으면 조업 시간이 없고,
706
+ * `computeOee` 가 가동률 대신 `missing` 을 낸다.
707
+ *
708
+ * 잰 것이 없으면 아무것도 내지 않는다. 빈 계수기로 0% 를 그리면 「재지 않았다」가 「안 돌았다」가 된다.
709
+ */
710
+ oeePart(id) {
711
+ const c = this.oeeCounters.get(id);
712
+ if (!c)
713
+ return {};
714
+ return { oee: computeOee(c, c.metricsUntilMs ?? 0) };
715
+ }
597
716
  noteUnhandled(e, key) {
598
717
  const at = Date.parse(String(e.eventTime ?? ''));
599
718
  const name = key ?? e.eventType;
@@ -1122,6 +1241,11 @@ export class ObservedReducer {
1122
1241
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
1123
1242
  /* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 아무 말이 없던 길이를 말할 수 없다. */
1124
1243
  heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v })),
1244
+ /*
1245
+ * OEE 계수기 — **원천이 되풀어 주지 않는 축**이다. 담지 않으면 재기동 뒤에 어제까지 쌓은 시간이
1246
+ * 사라지고 가동률이 그 순간부터 다시 세어진다.
1247
+ */
1248
+ oeeCounters: [...this.oeeCounters.entries()].map(([id, c]) => ({ id, ...c })),
1125
1249
  /*
1126
1250
  * 마스터데이터도 담는다 — 지난 기록에 없는 값이므로, 담지 않으면 되세울 때 유통기한이 빈다.
1127
1251
  * 변하지 않는 값이라 어느 시점의 저장본에서 꺼내도 같은 답이다.
@@ -1147,6 +1271,7 @@ export class ObservedReducer {
1147
1271
  this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map(x => [x.parent, x.quantities]));
1148
1272
  this.tasks = new Map((cp?.tasks ?? []).map(t => [t.id, { ...t }]));
1149
1273
  this.equipment = new Map((cp?.equipment ?? []).map(m => [m.id, { ...m }]));
1274
+ this.oeeCounters = new Map((cp?.oeeCounters ?? []).map((c) => [String(c.id), { ...emptyOeeCounters(), ...c, id: undefined }]));
1150
1275
  this.persons = new Map((cp?.persons ?? []).map(x => [x.id, { ...x }]));
1151
1276
  this.assets = new Map((cp?.assets ?? []).map(x => [x.id, { ...x }]));
1152
1277
  this.orders = new Map((cp?.orders ?? []).map(o => [o.id, { ...o }]));
@@ -1197,7 +1322,7 @@ export class ObservedReducer {
1197
1322
  persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
1198
1323
  assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
1199
1324
  tasks: [...this.tasks.values()].map(t => ({ ...t })),
1200
- equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
1325
+ equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
1201
1326
  orders: [...this.orders.values()].map(o => ({ ...o })),
1202
1327
  acked: [...this.acked]
1203
1328
  };
@@ -38,7 +38,6 @@ __export(index_exports, {
38
38
  YmsKernel: () => YmsKernel,
39
39
  attributeEnergy: () => attributeEnergy,
40
40
  compareStates: () => compareStates,
41
- computeOee: () => computeOee,
42
41
  constantDuration: () => constantDuration,
43
42
  counterfactualAt: () => counterfactualAt,
44
43
  demandWindowStart: () => demandWindowStart,
@@ -63,7 +62,7 @@ __export(index_exports, {
63
62
  });
64
63
  module.exports = __toCommonJS(index_exports);
65
64
 
66
- // node_modules/@operato/ops-contract/dist/capability.js
65
+ // ../ops-contract/dist/capability.js
67
66
  var CAPABILITIES = {
68
67
  operable: {
69
68
  key: "operable",
@@ -198,7 +197,7 @@ function stateFieldsOf(caps) {
198
197
  return [...out];
199
198
  }
200
199
 
201
- // node_modules/@operato/ops-contract/dist/contract.js
200
+ // ../ops-contract/dist/contract.js
202
201
  var ORDER_TERMINAL_STATUS = ["completed", "cancelled"];
203
202
  function isOrderTerminal(o) {
204
203
  const status = String(o?.status ?? "").trim().toLowerCase();
@@ -582,6 +581,15 @@ var OP_EVENT = {
582
581
  * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
583
582
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
584
583
  */
584
+ /**
585
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
586
+ *
587
+ * 시점의 전이(`equipment.status`)와 다른 사실이다. 전이는 설비가 내고, 이것은 **사람이 나중에
588
+ * 적는다.** 자재 대기처럼 신호를 내지 않는 정지는 이 길로만 들어온다.
589
+ *
590
+ * ISO 22400-2 의 가동률 계산이 이 구간들을 읽는다.
591
+ */
592
+ equipmentPeriod: "equipment.state.period",
585
593
  test: "test.result",
586
594
  /**
587
595
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
@@ -805,7 +813,7 @@ function gs1Namespaces(prefix) {
805
813
  return [`urn:epc:idpat:sgtin:${prefix}.`, `urn:epc:class:lgtin:${prefix}.`, `urn:epc:id:sgtin:${prefix}.`];
806
814
  }
807
815
 
808
- // node_modules/@operato/ops-contract/dist/capacity.js
816
+ // ../ops-contract/dist/capacity.js
809
817
  function isoDurationHours(iso2) {
810
818
  if (!iso2)
811
819
  return 0;
@@ -913,7 +921,7 @@ function analyzeCapacity(input) {
913
921
  };
914
922
  }
915
923
 
916
- // node_modules/@operato/ops-contract/dist/wms-profile.js
924
+ // ../ops-contract/dist/wms-profile.js
917
925
  var BIZSTEP = {
918
926
  receiving: "urn:epcglobal:cbv:bizstep:receiving",
919
927
  storing: "urn:epcglobal:cbv:bizstep:storing",
@@ -943,7 +951,7 @@ var WMS_TYPES = [
943
951
  { key: "packer", role: "equipment", label: "twin.type.packer", standardClass: { epcis: "object", isa95: "Equipment" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
944
952
  ];
945
953
 
946
- // node_modules/@operato/ops-contract/dist/yms-profile.js
954
+ // ../ops-contract/dist/yms-profile.js
947
955
  var YARD_BIZSTEP = {
948
956
  arriving: "urn:epcglobal:cbv:bizstep:arriving",
949
957
  // 게이트-인
@@ -969,7 +977,7 @@ var YMS_TYPES = [
969
977
  { key: "hostler", role: "equipment", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
970
978
  ];
971
979
 
972
- // node_modules/@operato/ops-contract/dist/mes-profile.js
980
+ // ../ops-contract/dist/mes-profile.js
973
981
  var MES_BIZSTEP = {
974
982
  receiving: "urn:epcglobal:cbv:bizstep:receiving",
975
983
  // 원자재 수령
@@ -1007,7 +1015,7 @@ var MES_TYPES = [
1007
1015
  { key: "assembler", role: "equipment", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1008
1016
  ];
1009
1017
 
1010
- // node_modules/@operato/ops-contract/dist/ems-profile.js
1018
+ // ../ops-contract/dist/ems-profile.js
1011
1019
  var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
1012
1020
  var METER_DIRECTION = ["import", "export", "bidirectional"];
1013
1021
  var EMS_PROPERTY = {
@@ -1328,7 +1336,7 @@ function isElectricalLocationType(type) {
1328
1336
  return EMS_LOCATION_TYPES.includes(type);
1329
1337
  }
1330
1338
 
1331
- // node_modules/@operato/ops-contract/dist/domain-catalog.js
1339
+ // ../ops-contract/dist/domain-catalog.js
1332
1340
  var locationKeys = (types) => types.filter((t) => t.role === "location").map((t) => t.key);
1333
1341
  var DOMAIN_CATALOG = {
1334
1342
  // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
@@ -1338,7 +1346,7 @@ var DOMAIN_CATALOG = {
1338
1346
  ems: { system: "ems", label: "twin.system.ems", types: EMS_TYPES, locationTypes: locationKeys(EMS_TYPES) }
1339
1347
  };
1340
1348
 
1341
- // node_modules/@operato/ops-contract/dist/domain-definition.js
1349
+ // ../ops-contract/dist/domain-definition.js
1342
1350
  var OP_PARAM = {
1343
1351
  /** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
1344
1352
  yield: "yield",
@@ -1347,7 +1355,7 @@ var OP_PARAM = {
1347
1355
  setupDuration: "setupDuration"
1348
1356
  };
1349
1357
 
1350
- // node_modules/@operato/ops-contract/dist/energy-ingest.js
1358
+ // ../ops-contract/dist/energy-ingest.js
1351
1359
  function resolveSubject(kind, localId, opts) {
1352
1360
  const declared = opts.identityOf?.(kind, localId);
1353
1361
  if (declared)
@@ -1358,7 +1366,7 @@ function periodFactId(tenantId, kind, subject, from, to) {
1358
1366
  return [tenantId, kind, subject, from, to].join("|");
1359
1367
  }
1360
1368
 
1361
- // node_modules/@operato/ops-contract/dist/epcis.js
1369
+ // ../ops-contract/dist/epcis.js
1362
1370
  var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
1363
1371
  var UTC_OFFSET = "+00:00";
1364
1372
  var DISP = {
@@ -1617,7 +1625,7 @@ function transformationEvent(p) {
1617
1625
  return e;
1618
1626
  }
1619
1627
 
1620
- // node_modules/@operato/ops-contract/dist/iso-duration.js
1628
+ // ../ops-contract/dist/iso-duration.js
1621
1629
  var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
1622
1630
  function parseIsoDuration(text) {
1623
1631
  if (typeof text !== "string")
@@ -1643,7 +1651,7 @@ function parseIsoDuration(text) {
1643
1651
  return sign ? -ms2 : ms2;
1644
1652
  }
1645
1653
 
1646
- // node_modules/@operato/ops-contract/dist/master-data.js
1654
+ // ../ops-contract/dist/master-data.js
1647
1655
  function expiryFromAttributes(attrs) {
1648
1656
  return readEpochMs(attrs?.[ILMD_ATTR.expiry]);
1649
1657
  }
@@ -1660,14 +1668,16 @@ function readEpochMs(raw) {
1660
1668
  return Number.isNaN(at) ? void 0 : at;
1661
1669
  }
1662
1670
 
1663
- // node_modules/@operato/ops-contract/dist/operational-ingest.js
1671
+ // ../ops-contract/dist/operational-ingest.js
1664
1672
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
1665
- var EQUIPMENT_STATUS = ["idle", "busy", "down"];
1673
+ var EQUIPMENT_STATUS = ["idle", "busy", "down", "setup", "planned-stop"];
1666
1674
  var PERSON_STATUS = ["idle", "busy"];
1667
1675
  var ASSET_STATUS = ["idle", "in-use"];
1668
1676
  var SPECS = {
1669
1677
  task: {
1670
1678
  eventType: OP_EVENT.task,
1679
+ match: ["taskId"],
1680
+ matchOrder: 50,
1671
1681
  identity: "taskId",
1672
1682
  /* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
1673
1683
  required: ["taskId", "kind", "status"],
@@ -1704,6 +1714,8 @@ var SPECS = {
1704
1714
  },
1705
1715
  equipment: {
1706
1716
  eventType: OP_EVENT.equipment,
1717
+ match: ["moverId"],
1718
+ matchOrder: 20,
1707
1719
  identity: "moverId",
1708
1720
  // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
1709
1721
  required: ["moverId", "kind", "status"],
@@ -1720,6 +1732,8 @@ var SPECS = {
1720
1732
  effectiveStart: "string",
1721
1733
  effectiveEnd: "string",
1722
1734
  recordTime: "string",
1735
+ /* 왜 이 상태가 됐나 — 설비가 알려 주면 싣는다. 사람이 나중에 정하는 사유는 구간 사실에 적는다. */
1736
+ reasonCode: "string",
1723
1737
  /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
1724
1738
  재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
1725
1739
  motion: "object"
@@ -1728,6 +1742,8 @@ var SPECS = {
1728
1742
  },
1729
1743
  person: {
1730
1744
  eventType: OP_EVENT.person,
1745
+ match: ["personId"],
1746
+ matchOrder: 30,
1731
1747
  identity: "personId",
1732
1748
  required: ["personId", "status"],
1733
1749
  fields: {
@@ -1745,6 +1761,8 @@ var SPECS = {
1745
1761
  },
1746
1762
  asset: {
1747
1763
  eventType: OP_EVENT.asset,
1764
+ match: ["assetId"],
1765
+ matchOrder: 40,
1748
1766
  identity: "assetId",
1749
1767
  required: ["assetId", "status"],
1750
1768
  fields: {
@@ -1762,6 +1780,8 @@ var SPECS = {
1762
1780
  },
1763
1781
  order: {
1764
1782
  eventType: OP_EVENT.order,
1783
+ match: ["orderId"],
1784
+ matchOrder: 60,
1765
1785
  identity: "orderId",
1766
1786
  /*
1767
1787
  * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
@@ -1791,12 +1811,23 @@ var SPECS = {
1791
1811
  },
1792
1812
  quality: {
1793
1813
  eventType: OP_EVENT.quality,
1814
+ match: ["moverId", "good"],
1815
+ matchOrder: 15,
1794
1816
  identity: "moverId",
1795
1817
  // vocabulary-guard: allow 저널 와이어 필드
1796
1818
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
1797
1819
  required: ["moverId", "good", "goodCount", "scrapCount"],
1798
1820
  // vocabulary-guard: allow
1799
- fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", recordTime: "string" }
1821
+ /*
1822
+ * `taskId` 는 **귀속**이다 — 이 산출이 어느 작업의 것인가. 수량의 뜻은 바뀌지 않는다(설비 누적).
1823
+ *
1824
+ * 왜 필요한가: 작업자가 올리는 실적은 작업 단위인데 이 갈래의 정체는 설비뿐이라, 두 사실이 만나는
1825
+ * 자리가 없었다. 귀속이 없으면 「이 작업에서 몇 개 나왔나」를 저널에서 되짚을 방법이 없다.
1826
+ *
1827
+ * 정체를 `taskId` 로 바꾸지 않는 이유는 OEE 가 설비 단위여서다. 정체를 옮기면 설비 누적을 셀 수
1828
+ * 없어진다.
1829
+ */
1830
+ fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", taskId: "string", recordTime: "string" }
1800
1831
  // vocabulary-guard: allow
1801
1832
  },
1802
1833
  /*
@@ -1809,6 +1840,51 @@ var SPECS = {
1809
1840
  * `propertyMeasurements` 안쪽은 재검사하지 않는다 — 그 모양은 `PropertyMeasurement` 계약이고,
1810
1841
  * 여기서 두 번 지키면 두 벌이 된다(설비 `motion` 과 같은 규율).
1811
1842
  */
1843
+ /*
1844
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
1845
+ *
1846
+ * ── 전이만으로는 안 되는 이유 (2026-08-30) ───────────────────────────────
1847
+ * 세 가지다. 셋째가 결정적이다.
1848
+ *
1849
+ * 사유가 붙을 자리가 없다 기계가 서는 순간에는 왜 섰는지 아무도 모른다. 작업자가 라인이 다시
1850
+ * 돈 뒤에 적는다. 그 사이에 전이가 더 있으면 어느 전이에 붙일지 정할 수 없다
1851
+ * 계획·비계획을 나중에 정한다 ISO 22400 은 계획정지를 계획 조업 시간에서 빼고 고장은 빼지 않는다.
1852
+ * 그 분류는 전이가 일어난 순간에 모른다
1853
+ * 전이를 내지 않는 정지가 많다 자재 대기 · 앞 공정 대기 · 작업자 부재. 기계는 전원이 켜진 채 `idle`
1854
+ * 이고 신호가 하나도 안 나온다. OEE 에서 가장 크게 깎이는 것이 보통 이 시간이다
1855
+ *
1856
+ * 마지막이 감시 시스템과 MES 가 갈리는 자리이기도 하다. 설비가 말하는 것만 모으면 감시이고,
1857
+ * **설비가 말하지 못하는 것을 사람이 적어 넣는 자리**가 MES 다.
1858
+ *
1859
+ * ── 이름이 `downtime` 이 아닌 이유 ────────────────────────────────────────
1860
+ * 이 구간은 `setup` 과 `planned-stop` 도 나른다. 그 둘은 정지가 아니다. 담는 것은 **상태 구간**이고,
1861
+ * 어느 상태인지는 `status` 가 말한다.
1862
+ *
1863
+ * ── 되돌아가 적는 사실이다 ───────────────────────────────────────────────
1864
+ * 봉투의 사건 시각은 **구간의 끝**(그때 성립한다)이고, 적은 시각은 `recordTime` 이다. 둘이 갈려 있어야
1865
+ * 「언제 일어났나」와 「언제 알았나」를 구별할 수 있다.
1866
+ */
1867
+ "equipment-period": {
1868
+ eventType: OP_EVENT.equipmentPeriod,
1869
+ match: ["moverId", "status", "from", "to"],
1870
+ matchOrder: 10,
1871
+ identity: "moverId",
1872
+ // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
1873
+ required: ["moverId", "status", "from", "to"],
1874
+ // vocabulary-guard: allow 위와 같은 이유
1875
+ fields: {
1876
+ moverId: "string",
1877
+ status: "string",
1878
+ from: "string",
1879
+ to: "string",
1880
+ // vocabulary-guard: allow
1881
+ /* 사람이 정한 사유 — 없을 수 있다(적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다). */
1882
+ reasonCode: "string",
1883
+ decidedBy: "string",
1884
+ recordTime: "string"
1885
+ },
1886
+ enums: { status: EQUIPMENT_STATUS }
1887
+ },
1812
1888
  /*
1813
1889
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나.
1814
1890
  *
@@ -1826,6 +1902,8 @@ var SPECS = {
1826
1902
  */
1827
1903
  disposition: {
1828
1904
  eventType: OP_EVENT.disposition,
1905
+ match: ["subjectId", "decision"],
1906
+ matchOrder: 80,
1829
1907
  identity: "subjectId",
1830
1908
  /* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
1831
1909
  required: ["subjectId", "decision"],
@@ -1844,6 +1922,8 @@ var SPECS = {
1844
1922
  },
1845
1923
  test: {
1846
1924
  eventType: OP_EVENT.test,
1925
+ match: ["testableObjectId"],
1926
+ matchOrder: 70,
1847
1927
  identity: "testableObjectId",
1848
1928
  /* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
1849
1929
  required: ["testableObjectId", "specId"],
@@ -1877,12 +1957,16 @@ var SPECS = {
1877
1957
  */
1878
1958
  complete: {
1879
1959
  eventType: OP_EVENT.complete,
1960
+ match: ["completeAxis"],
1961
+ matchOrder: 100,
1880
1962
  identity: "completeAxis",
1881
1963
  required: ["completeAxis", "since"],
1882
1964
  fields: { completeAxis: "string", since: "string", recordTime: "string" }
1883
1965
  },
1884
1966
  observation: {
1885
1967
  eventType: OP_EVENT.observation,
1968
+ match: ["locationId", "propertyId"],
1969
+ matchOrder: 90,
1886
1970
  identity: "locationId",
1887
1971
  /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
1888
1972
  required: ["locationId", "propertyId"],
@@ -1900,8 +1984,9 @@ var SPECS = {
1900
1984
  }
1901
1985
  }
1902
1986
  };
1987
+ var MATCH_ORDER = Object.keys(SPECS).sort((a, b) => (SPECS[a].matchOrder ?? 1e3) - (SPECS[b].matchOrder ?? 1e3));
1903
1988
 
1904
- // node_modules/@operato/ops-contract/dist/operations-capability.js
1989
+ // ../ops-contract/dist/operations-capability.js
1905
1990
  function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
1906
1991
  if (!(toMs > fromMs))
1907
1992
  return 0;
@@ -1971,6 +2056,39 @@ function operationsCapabilityOf(input) {
1971
2056
  };
1972
2057
  }
1973
2058
 
2059
+ // ../ops-contract/dist/oee.js
2060
+ function computeOee(c, nowMs) {
2061
+ const missing = [];
2062
+ const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
2063
+ if (planned <= 0)
2064
+ missing.push("planned-busy-time");
2065
+ const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
2066
+ const availability = planned > 0 ? Math.min(1, c.runMs / planned) : void 0;
2067
+ const produced = c.goodCount + c.scrapCount;
2068
+ if (produced <= 0)
2069
+ missing.push("produced-quantity");
2070
+ const quality = produced > 0 ? c.goodCount / produced : void 0;
2071
+ if (c.plannedRunTimePerItemMs == null)
2072
+ missing.push("planned-run-time-per-item");
2073
+ else if (c.runMs <= 0)
2074
+ missing.push("actual-production-time");
2075
+ const performance = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
2076
+ const overall = availability != null && performance != null && quality != null ? availability * performance * quality : void 0;
2077
+ return {
2078
+ ...availability != null ? { availability } : {},
2079
+ ...performance != null ? { performance } : {},
2080
+ ...quality != null ? { quality } : {},
2081
+ ...overall != null ? { overall } : {},
2082
+ ...missing.length ? { missing } : {},
2083
+ runMs: c.runMs,
2084
+ setupMs: c.setupMs,
2085
+ downMs: c.downMs,
2086
+ delayMs,
2087
+ goodCount: c.goodCount,
2088
+ scrapCount: c.scrapCount
2089
+ };
2090
+ }
2091
+
1974
2092
  // src/divergence.ts
1975
2093
  function diffBy(predicted, actual, idOf, valOf) {
1976
2094
  const p = new Map(predicted.map((e) => [idOf(e), valOf(e)]));
@@ -2207,6 +2325,9 @@ function effectiveOf(r) {
2207
2325
  ...r?.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
2208
2326
  };
2209
2327
  }
2328
+ function emptyOeeCounters() {
2329
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
2330
+ }
2210
2331
  var ObservedReducer = class {
2211
2332
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
2212
2333
  master = /* @__PURE__ */ new Map();
@@ -2256,6 +2377,13 @@ var ObservedReducer = class {
2256
2377
  corrections = [];
2257
2378
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
2258
2379
  unhandled = /* @__PURE__ */ new Map();
2380
+ /**
2381
+ * **설비마다 쌓는 OEE 계수기** — 상태가 아니라 누적이다.
2382
+ *
2383
+ * 원천이 되풀어 주지 않는 축이라 저장본에 함께 담는다(§`serialize`). 담지 않으면 재기동 뒤에 어제까지
2384
+ * 쌓은 시간이 사라지고, 가동률이 그 순간부터 다시 세어진다.
2385
+ */
2386
+ oeeCounters = /* @__PURE__ */ new Map();
2259
2387
  /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
2260
2388
  testSpecs = /* @__PURE__ */ new Map();
2261
2389
  /**
@@ -2547,6 +2675,48 @@ var ObservedReducer = class {
2547
2675
  this.touchLocation(d.location);
2548
2676
  break;
2549
2677
  }
2678
+ case OP_EVENT.quality: {
2679
+ const d = e.data;
2680
+ if (!d?.moverId) break;
2681
+ if (this.stale(`quality:${d.moverId}`, e)) return;
2682
+ const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
2683
+ c.goodCount = d.goodCount;
2684
+ c.scrapCount = d.scrapCount;
2685
+ this.oeeCounters.set(d.moverId, c);
2686
+ this.markHeard(d.moverId, e);
2687
+ break;
2688
+ }
2689
+ case OP_EVENT.equipmentPeriod: {
2690
+ const d = e.data;
2691
+ if (!d?.moverId || !d?.from || !d?.to) break;
2692
+ const from = Date.parse(d.from);
2693
+ const to = Date.parse(d.to);
2694
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) break;
2695
+ const c = this.oeeCounters.get(d.moverId) ?? emptyOeeCounters();
2696
+ const ms2 = to - from;
2697
+ if (d.status === "busy") c.runMs += ms2;
2698
+ else if (d.status === "setup") c.setupMs += ms2;
2699
+ else if (d.status === "down") c.downMs += ms2;
2700
+ else if (d.status === "planned-stop") c.holdMs += ms2;
2701
+ else if (d.status !== "idle") break;
2702
+ c.metricsSinceMs = c.metricsSinceMs === void 0 ? from : Math.min(c.metricsSinceMs, from);
2703
+ c.metricsUntilMs = c.metricsUntilMs === void 0 ? to : Math.max(c.metricsUntilMs, to);
2704
+ this.oeeCounters.set(d.moverId, c);
2705
+ this.markHeard(d.moverId, e);
2706
+ break;
2707
+ }
2708
+ case OP_EVENT.disposition: {
2709
+ const d = e.data;
2710
+ if (!d?.subjectId || !d?.decision) break;
2711
+ if (this.stale(`disposition:${d.subjectId}`, e)) return;
2712
+ const parts = [...this.items.values()].filter((i) => i.epc === d.subjectId);
2713
+ if (!parts.length) {
2714
+ this.noteUnhandled(e, `${OP_EVENT.disposition}:unknown-subject`);
2715
+ break;
2716
+ }
2717
+ for (const part of parts) part.nonconformance = { ...d };
2718
+ break;
2719
+ }
2550
2720
  case OP_EVENT.observation: {
2551
2721
  const d = e.data;
2552
2722
  if (!d?.locationId || !d?.propertyId) break;
@@ -2632,6 +2802,23 @@ var ObservedReducer = class {
2632
2802
  * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
2633
2803
  * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
2634
2804
  */
2805
+ /**
2806
+ * 그 설비의 OEE — **원천이 말해 준 구간까지만 센다.**
2807
+ *
2808
+ * 조업 시간의 끝을 시계에서 가져오지 않는다. 시계를 쓰면 원천이 말을 멈춘 동안에도 조업 시간만 자라
2809
+ * 가동률이 계속 떨어지는데, 그것은 설비의 사실이 아니라 연결의 사실이다. 미러가 아는 것은
2810
+ * **구간으로 들은 시간**뿐이고, 그 밖의 시간에 그 설비가 무엇을 했는지는 모른다.
2811
+ *
2812
+ * 그래서 창은 `[가장 이른 from, 가장 늦은 to]` 다. 구간을 하나도 못 들었으면 조업 시간이 없고,
2813
+ * `computeOee` 가 가동률 대신 `missing` 을 낸다.
2814
+ *
2815
+ * 잰 것이 없으면 아무것도 내지 않는다. 빈 계수기로 0% 를 그리면 「재지 않았다」가 「안 돌았다」가 된다.
2816
+ */
2817
+ oeePart(id) {
2818
+ const c = this.oeeCounters.get(id);
2819
+ if (!c) return {};
2820
+ return { oee: computeOee(c, c.metricsUntilMs ?? 0) };
2821
+ }
2635
2822
  noteUnhandled(e, key) {
2636
2823
  const at = Date.parse(String(e.eventTime ?? ""));
2637
2824
  const name = key ?? e.eventType;
@@ -3007,6 +3194,11 @@ var ObservedReducer = class {
3007
3194
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
3008
3195
  /* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 아무 말이 없던 길이를 말할 수 없다. */
3009
3196
  heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v })),
3197
+ /*
3198
+ * OEE 계수기 — **원천이 되풀어 주지 않는 축**이다. 담지 않으면 재기동 뒤에 어제까지 쌓은 시간이
3199
+ * 사라지고 가동률이 그 순간부터 다시 세어진다.
3200
+ */
3201
+ oeeCounters: [...this.oeeCounters.entries()].map(([id, c]) => ({ id, ...c })),
3010
3202
  /*
3011
3203
  * 마스터데이터도 담는다 — 지난 기록에 없는 값이므로, 담지 않으면 되세울 때 유통기한이 빈다.
3012
3204
  * 변하지 않는 값이라 어느 시점의 저장본에서 꺼내도 같은 답이다.
@@ -3034,6 +3226,9 @@ var ObservedReducer = class {
3034
3226
  this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map((x) => [x.parent, x.quantities]));
3035
3227
  this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, { ...t }]));
3036
3228
  this.equipment = new Map((cp?.equipment ?? []).map((m) => [m.id, { ...m }]));
3229
+ this.oeeCounters = new Map(
3230
+ (cp?.oeeCounters ?? []).map((c) => [String(c.id), { ...emptyOeeCounters(), ...c, id: void 0 }])
3231
+ );
3037
3232
  this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
3038
3233
  this.assets = new Map((cp?.assets ?? []).map((x) => [x.id, { ...x }]));
3039
3234
  this.orders = new Map((cp?.orders ?? []).map((o) => [o.id, { ...o }]));
@@ -3083,7 +3278,7 @@ var ObservedReducer = class {
3083
3278
  persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
3084
3279
  assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
3085
3280
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
3086
- equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
3281
+ equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
3087
3282
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3088
3283
  acked: [...this.acked]
3089
3284
  };
@@ -3351,26 +3546,6 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3351
3546
  }
3352
3547
  return out;
3353
3548
  }
3354
- function computeOee(c, nowMs) {
3355
- const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
3356
- const uptime = Math.max(0, planned - c.setupMs - c.downMs);
3357
- const availability = planned > 0 ? uptime / planned : 1;
3358
- const performance = uptime > 0 ? Math.min(1, c.runMs / uptime) : c.runMs > 0 ? 1 : 0;
3359
- const totalQ = c.goodCount + c.scrapCount;
3360
- const quality = totalQ > 0 ? c.goodCount / totalQ : 1;
3361
- return {
3362
- availability,
3363
- performance,
3364
- quality,
3365
- overall: availability * performance * quality,
3366
- runMs: c.runMs,
3367
- setupMs: c.setupMs,
3368
- downMs: c.downMs,
3369
- idleMs: Math.max(0, uptime - c.runMs),
3370
- goodCount: c.goodCount,
3371
- scrapCount: c.scrapCount
3372
- };
3373
- }
3374
3549
  var ENERGY_STATE_FIELDS = [
3375
3550
  ...stateFieldsOf(["metered", "curtailable", "energyGenerating", "energyStoring", "switching"]),
3376
3551
  "measuredAt"
@@ -3995,7 +4170,7 @@ var FlowEngine = class {
3995
4170
  * 창은 지표 자신이 말해 준다 — 계획 시간은 가동·준비·고장·유휴의 합이다. 그래서 그 합만큼
3996
4171
  * 뒤로 물려 창을 다시 세운다. 지표가 없으면 창도 없다(그때는 지금부터 새로 잰다).
3997
4172
  */
3998
- ...oee ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.idleMs ?? 0)) } : {},
4173
+ ...oee ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.delayMs ?? 0)) } : {},
3999
4174
  /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
4000
4175
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
4001
4176
  ...m.held ? { held: true } : {},
@@ -9420,7 +9595,6 @@ function electricityCost(input) {
9420
9595
  YmsKernel,
9421
9596
  attributeEnergy,
9422
9597
  compareStates,
9423
- computeOee,
9424
9598
  constantDuration,
9425
9599
  counterfactualAt,
9426
9600
  demandWindowStart,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.8.4",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {
@@ -28,6 +28,6 @@
28
28
  "test": "node --test test/*.test.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@operato/ops-contract": "^0.4.0"
31
+ "@operato/ops-contract": "^0.7.0"
32
32
  }
33
33
  }