@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.
- package/dist/observed-reducer.d.ts +41 -1
- package/dist/observed-reducer.js +126 -1
- package/dist-cjs/index.cjs +111 -2
- package/package.json +2 -2
|
@@ -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
|
* 팔레트 물품에 **담고 있는 것**을 채운다 — 이미 아는 값을 덮지 않는다.
|
package/dist/observed-reducer.js
CHANGED
|
@@ -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
|
};
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"@operato/ops-contract": "^0.8.0"
|
|
32
32
|
}
|
|
33
33
|
}
|