@operato/twin-kernel 0.9.0 → 0.10.1

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 { 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
  };
@@ -1676,6 +1676,8 @@ var ASSET_STATUS = ["idle", "in-use"];
1676
1676
  var SPECS = {
1677
1677
  task: {
1678
1678
  eventType: OP_EVENT.task,
1679
+ match: ["taskId"],
1680
+ matchOrder: 50,
1679
1681
  identity: "taskId",
1680
1682
  /* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
1681
1683
  required: ["taskId", "kind", "status"],
@@ -1712,6 +1714,8 @@ var SPECS = {
1712
1714
  },
1713
1715
  equipment: {
1714
1716
  eventType: OP_EVENT.equipment,
1717
+ match: ["moverId"],
1718
+ matchOrder: 20,
1715
1719
  identity: "moverId",
1716
1720
  // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
1717
1721
  required: ["moverId", "kind", "status"],
@@ -1738,6 +1742,8 @@ var SPECS = {
1738
1742
  },
1739
1743
  person: {
1740
1744
  eventType: OP_EVENT.person,
1745
+ match: ["personId"],
1746
+ matchOrder: 30,
1741
1747
  identity: "personId",
1742
1748
  required: ["personId", "status"],
1743
1749
  fields: {
@@ -1755,6 +1761,8 @@ var SPECS = {
1755
1761
  },
1756
1762
  asset: {
1757
1763
  eventType: OP_EVENT.asset,
1764
+ match: ["assetId"],
1765
+ matchOrder: 40,
1758
1766
  identity: "assetId",
1759
1767
  required: ["assetId", "status"],
1760
1768
  fields: {
@@ -1772,6 +1780,8 @@ var SPECS = {
1772
1780
  },
1773
1781
  order: {
1774
1782
  eventType: OP_EVENT.order,
1783
+ match: ["orderId"],
1784
+ matchOrder: 60,
1775
1785
  identity: "orderId",
1776
1786
  /*
1777
1787
  * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
@@ -1801,12 +1811,23 @@ var SPECS = {
1801
1811
  },
1802
1812
  quality: {
1803
1813
  eventType: OP_EVENT.quality,
1814
+ match: ["moverId", "good"],
1815
+ matchOrder: 15,
1804
1816
  identity: "moverId",
1805
1817
  // vocabulary-guard: allow 저널 와이어 필드
1806
1818
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
1807
1819
  required: ["moverId", "good", "goodCount", "scrapCount"],
1808
1820
  // vocabulary-guard: allow
1809
- 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" }
1810
1831
  // vocabulary-guard: allow
1811
1832
  },
1812
1833
  /*
@@ -1845,6 +1866,8 @@ var SPECS = {
1845
1866
  */
1846
1867
  "equipment-period": {
1847
1868
  eventType: OP_EVENT.equipmentPeriod,
1869
+ match: ["moverId", "status", "from", "to"],
1870
+ matchOrder: 10,
1848
1871
  identity: "moverId",
1849
1872
  // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
1850
1873
  required: ["moverId", "status", "from", "to"],
@@ -1879,6 +1902,8 @@ var SPECS = {
1879
1902
  */
1880
1903
  disposition: {
1881
1904
  eventType: OP_EVENT.disposition,
1905
+ match: ["subjectId", "decision"],
1906
+ matchOrder: 80,
1882
1907
  identity: "subjectId",
1883
1908
  /* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
1884
1909
  required: ["subjectId", "decision"],
@@ -1897,6 +1922,8 @@ var SPECS = {
1897
1922
  },
1898
1923
  test: {
1899
1924
  eventType: OP_EVENT.test,
1925
+ match: ["testableObjectId"],
1926
+ matchOrder: 70,
1900
1927
  identity: "testableObjectId",
1901
1928
  /* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
1902
1929
  required: ["testableObjectId", "specId"],
@@ -1930,12 +1957,16 @@ var SPECS = {
1930
1957
  */
1931
1958
  complete: {
1932
1959
  eventType: OP_EVENT.complete,
1960
+ match: ["completeAxis"],
1961
+ matchOrder: 100,
1933
1962
  identity: "completeAxis",
1934
1963
  required: ["completeAxis", "since"],
1935
1964
  fields: { completeAxis: "string", since: "string", recordTime: "string" }
1936
1965
  },
1937
1966
  observation: {
1938
1967
  eventType: OP_EVENT.observation,
1968
+ match: ["locationId", "propertyId"],
1969
+ matchOrder: 90,
1939
1970
  identity: "locationId",
1940
1971
  /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
1941
1972
  required: ["locationId", "propertyId"],
@@ -1953,6 +1984,7 @@ var SPECS = {
1953
1984
  }
1954
1985
  }
1955
1986
  };
1987
+ var MATCH_ORDER = Object.keys(SPECS).sort((a, b) => (SPECS[a].matchOrder ?? 1e3) - (SPECS[b].matchOrder ?? 1e3));
1956
1988
 
1957
1989
  // ../ops-contract/dist/operations-capability.js
1958
1990
  function workingHoursBetween(calendar, fromMs, toMs, utcOffsetMinutes) {
@@ -2293,6 +2325,9 @@ function effectiveOf(r) {
2293
2325
  ...r?.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
2294
2326
  };
2295
2327
  }
2328
+ function emptyOeeCounters() {
2329
+ return { runMs: 0, setupMs: 0, downMs: 0, holdMs: 0, goodCount: 0, scrapCount: 0 };
2330
+ }
2296
2331
  var ObservedReducer = class {
2297
2332
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
2298
2333
  master = /* @__PURE__ */ new Map();
@@ -2342,6 +2377,13 @@ var ObservedReducer = class {
2342
2377
  corrections = [];
2343
2378
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
2344
2379
  unhandled = /* @__PURE__ */ new Map();
2380
+ /**
2381
+ * **설비마다 쌓는 OEE 계수기** — 상태가 아니라 누적이다.
2382
+ *
2383
+ * 원천이 되풀어 주지 않는 축이라 저장본에 함께 담는다(§`serialize`). 담지 않으면 재기동 뒤에 어제까지
2384
+ * 쌓은 시간이 사라지고, 가동률이 그 순간부터 다시 세어진다.
2385
+ */
2386
+ oeeCounters = /* @__PURE__ */ new Map();
2345
2387
  /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
2346
2388
  testSpecs = /* @__PURE__ */ new Map();
2347
2389
  /**
@@ -2633,6 +2675,48 @@ var ObservedReducer = class {
2633
2675
  this.touchLocation(d.location);
2634
2676
  break;
2635
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
+ }
2636
2720
  case OP_EVENT.observation: {
2637
2721
  const d = e.data;
2638
2722
  if (!d?.locationId || !d?.propertyId) break;
@@ -2718,6 +2802,23 @@ var ObservedReducer = class {
2718
2802
  * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
2719
2803
  * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
2720
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
+ }
2721
2822
  noteUnhandled(e, key) {
2722
2823
  const at = Date.parse(String(e.eventTime ?? ""));
2723
2824
  const name = key ?? e.eventType;
@@ -3093,6 +3194,11 @@ var ObservedReducer = class {
3093
3194
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
3094
3195
  /* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 아무 말이 없던 길이를 말할 수 없다. */
3095
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 })),
3096
3202
  /*
3097
3203
  * 마스터데이터도 담는다 — 지난 기록에 없는 값이므로, 담지 않으면 되세울 때 유통기한이 빈다.
3098
3204
  * 변하지 않는 값이라 어느 시점의 저장본에서 꺼내도 같은 답이다.
@@ -3120,6 +3226,9 @@ var ObservedReducer = class {
3120
3226
  this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map((x) => [x.parent, x.quantities]));
3121
3227
  this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, { ...t }]));
3122
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
+ );
3123
3232
  this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
3124
3233
  this.assets = new Map((cp?.assets ?? []).map((x) => [x.id, { ...x }]));
3125
3234
  this.orders = new Map((cp?.orders ?? []).map((o) => [o.id, { ...o }]));
@@ -3169,7 +3278,7 @@ var ObservedReducer = class {
3169
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) })),
3170
3279
  assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
3171
3280
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
3172
- 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) })),
3173
3282
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3174
3283
  acked: [...this.acked]
3175
3284
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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.5.0"
31
+ "@operato/ops-contract": "^0.8.0"
32
32
  }
33
33
  }