@things-factory/headless-twin 10.0.6 → 10.0.7
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/README.md +37 -24
- package/dist-server/engine/entity-delta.d.ts +13 -1
- package/dist-server/engine/entity-delta.js +130 -16
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +4 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +20 -3
- package/dist-server/engine/kpi-fold.js +21 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +9 -0
- package/dist-server/engine/kpi-query.js +118 -19
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +141 -0
- package/dist-server/engine/kpi-target.js +137 -0
- package/dist-server/engine/kpi-target.js.map +1 -0
- package/dist-server/engine/live-attentions.d.ts +4 -2
- package/dist-server/engine/live-attentions.js +5 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/measured-estimator.d.ts +50 -0
- package/dist-server/engine/measured-estimator.js +78 -0
- package/dist-server/engine/measured-estimator.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +23 -0
- package/dist-server/engine/model-basis.js +100 -0
- package/dist-server/engine/model-basis.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +2 -2
- package/dist-server/engine/oee-accumulator.js +4 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +49 -0
- package/dist-server/engine/spec-coverage.js +60 -0
- package/dist-server/engine/spec-coverage.js.map +1 -0
- package/dist-server/engine/structure-diff.d.ts +25 -0
- package/dist-server/engine/structure-diff.js +63 -0
- package/dist-server/engine/structure-diff.js.map +1 -0
- package/dist-server/engine/travel-estimator.d.ts +57 -0
- package/dist-server/engine/travel-estimator.js +120 -0
- package/dist-server/engine/travel-estimator.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +153 -17
- package/dist-server/engine/twin-engine.js +473 -81
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +5 -5
- package/dist-server/engine/warm-start.js +4 -4
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +21 -14
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +2 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +307 -1
- package/dist-server/service/reference/reference-master.js +96 -8
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -3
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js +3 -3
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +11 -0
- package/dist-server/service/twin-event/twin-event.js +6 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +1 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +9 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-area.js +1 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +11 -0
- package/dist-server/service/twin-space/twin-space.js +5 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/index.d.ts +2 -0
- package/dist-server/service/twin-structure/index.js +6 -0
- package/dist-server/service/twin-structure/index.js.map +1 -0
- package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
- package/dist-server/service/twin-structure/twin-structure.js +97 -0
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
- package/dist-server/service/twin-target/index.d.ts +4 -0
- package/dist-server/service/twin-target/index.js +8 -0
- package/dist-server/service/twin-target/index.js.map +1 -0
- package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
- package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
- package/dist-server/service/twin-target/twin-target.d.ts +25 -0
- package/dist-server/service/twin-target/twin-target.js +112 -0
- package/dist-server/service/twin-target/twin-target.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +40 -8
- package/server/engine/kpi-query.ts +129 -19
- package/server/engine/kpi-target.ts +226 -0
- package/server/engine/live-attentions.ts +12 -2
- package/server/engine/measured-estimator.ts +91 -0
- package/server/engine/model-basis.ts +94 -0
- package/server/engine/oee-accumulator.ts +5 -5
- package/server/engine/spec-coverage.ts +85 -0
- package/server/engine/structure-diff.ts +88 -0
- package/server/engine/travel-estimator.ts +133 -0
- package/server/engine/twin-engine.ts +498 -84
- package/server/engine/warm-start.ts +8 -8
- package/server/service/index.ts +7 -0
- package/server/service/reference/reference-live.ts +2 -1
- package/server/service/reference/reference-master.ts +383 -10
- package/server/service/reference/reference-resolver.ts +3 -3
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-attention/twin-attention-query.ts +1 -1
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-event/twin-event-keys.ts +2 -2
- package/server/service/twin-event/twin-event.ts +15 -1
- package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +52 -4
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
- package/server/service/twin-space/twin-space-area.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +3 -3
- package/server/service/twin-space/twin-space.ts +14 -0
- package/server/service/twin-structure/index.ts +3 -0
- package/server/service/twin-structure/twin-structure.ts +93 -0
- package/server/service/twin-target/index.ts +5 -0
- package/server/service/twin-target/twin-target-resolver.ts +124 -0
- package/server/service/twin-target/twin-target.ts +97 -0
- package/test/capability-mapping.test.ts +5 -5
- package/test/duration-estimators.test.ts +144 -0
- package/test/entity-delta.test.ts +150 -19
- package/test/ingest-bench.test.ts +9 -9
- package/test/kpi-fold.test.ts +232 -3
- package/test/live-mirror-parity.test.ts +53 -19
- package/test/master-to-twin.test.ts +87 -13
- package/test/model-basis.test.ts +86 -0
- package/test/oee-accumulator.test.ts +9 -9
- package/test/scale-twin-bench.test.ts +22 -22
- package/test/spec-coverage.test.ts +113 -0
- package/test/streamline-e2e.test.ts +10 -10
- package/test/structure-revision-db.test.ts +310 -0
- package/test/twin-event-keys.test.ts +2 -2
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +9 -9
|
@@ -63,14 +63,14 @@ test('능력 소싱 구조: 4 자원/위치 능력은 카탈로그 타입에서,
|
|
|
63
63
|
|
|
64
64
|
test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload 에 실림 (커널→보드 도달)', () => {
|
|
65
65
|
// storable 노드 → occupancy/capacity (+ operable status)
|
|
66
|
-
const nodeD: any = buildEntityDeltas({
|
|
66
|
+
const nodeD: any = buildEntityDeltas({ locations: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
|
|
67
67
|
for (const f of stateFieldsOf(['storable'])) assert.ok(f in nodeD, `storable payload.${f}`)
|
|
68
68
|
assert.ok('status' in nodeD, 'operable(노드 status) payload')
|
|
69
69
|
|
|
70
70
|
// mobile+operable 무버 → location/motion/status
|
|
71
|
-
const
|
|
72
|
-
for (const f of stateFieldsOf(['mobile'])) assert.ok(f in
|
|
73
|
-
for (const f of stateFieldsOf(['operable'])) assert.ok(f in
|
|
71
|
+
const equipmentD: any = buildEntityDeltas({ equipment: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
|
|
72
|
+
for (const f of stateFieldsOf(['mobile'])) assert.ok(f in equipmentD, `mobile payload.${f}`)
|
|
73
|
+
for (const f of stateFieldsOf(['operable'])) assert.ok(f in equipmentD, `operable payload.${f}`)
|
|
74
74
|
|
|
75
75
|
// trackable 오더 → progress/held (lifecycle 은 payload 에서 'status' 로 표면화 — 이름-매핑 시임)
|
|
76
76
|
const orderD: any = buildEntityDeltas({ orders: [{ id: 'o', status: 'picking', progress: 0.5, held: false }] }).find(x => x.tag === 'o')!.data
|
|
@@ -81,7 +81,7 @@ test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload
|
|
|
81
81
|
test('시임 문서화 · processable output(양품/불량)은 payload oee.good/scrap 으로 표면화', () => {
|
|
82
82
|
// 계약 stateField = 'output' 이나, 라이브 payload 는 이를 oee(정수%+good/scrap)로 집약해 표면화.
|
|
83
83
|
const d: any = buildEntityDeltas({
|
|
84
|
-
|
|
84
|
+
equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
|
|
85
85
|
}).find(x => x.tag === 'c')!.data
|
|
86
86
|
assert.ok(d.oee, 'processable → payload.oee 존재')
|
|
87
87
|
assert.equal(d.oee.good, 10, 'output.good = oee.good')
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 소요시간 추정기 — **상수를 데이터로 바꾸는 두 층(실측 · 거리×속도).**
|
|
3
|
+
*
|
|
4
|
+
* 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 호스트가 넣는 두 가지를
|
|
5
|
+
* 여기서 고정한다. 핵심 규율은 하나다 — **모르면 답하지 않는다**(undefined → 커널이 명세·상수로).
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
import { EQUIPMENT_PROPERTY, buildTravelEstimator, chainEstimators } from '../server/engine/travel-estimator.ts'
|
|
11
|
+
import { DEFAULT_MIN_SAMPLES, buildMeasuredEstimator } from '../server/engine/measured-estimator.ts'
|
|
12
|
+
|
|
13
|
+
const layout = { dock: { x: 0, y: 0 }, rack: { x: 30, y: 40 } } // 거리 50m
|
|
14
|
+
const equipment = (uom?: string, value = '2') => [
|
|
15
|
+
{ id: 'fk-1', kind: 'forklift', properties: [{ id: EQUIPMENT_PROPERTY.speed, value, ...(uom ? { uom } : {}) }] }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/* ── 거리 × 속도 ────────────────────────────────────────────────────────── */
|
|
19
|
+
|
|
20
|
+
test('거리와 속도가 있으면 이동시간을 계산한다 — 상수 30초가 아니라 50m/2(m/s)=25초', () => {
|
|
21
|
+
const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
|
|
22
|
+
assert.deepEqual(reasons, [])
|
|
23
|
+
assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('단위 코드를 표준대로 환산한다 — KMH 는 m/s 가 아니다', () => {
|
|
27
|
+
const { estimator } = buildTravelEstimator({ layout, equipment: equipment('KMH', '7.2'), unit: 'm' })
|
|
28
|
+
// 7.2 km/h = 2 m/s → 50m = 25초
|
|
29
|
+
assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('단위 없는 속도는 m/s 라고 가정하지 않는다 — 3 이 초속 3m 인지 시속 3km 인지 뜻이 다르다', () => {
|
|
33
|
+
const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment(undefined), unit: 'm' })
|
|
34
|
+
assert.equal(estimator, undefined)
|
|
35
|
+
assert.ok(reasons.some(r => r.includes('unit of measure')))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('모르는 단위 코드는 받지 않는다', () => {
|
|
39
|
+
const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('WAT'), unit: 'm' })
|
|
40
|
+
assert.equal(estimator, undefined)
|
|
41
|
+
assert.ok(reasons.some(r => r.includes('unknown speed unit')))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('좌표가 없으면 만들지 않고 이유를 남긴다', () => {
|
|
45
|
+
const { estimator, reasons } = buildTravelEstimator({ layout: {}, equipment: equipment('MTS'), unit: 'm' })
|
|
46
|
+
assert.equal(estimator, undefined)
|
|
47
|
+
assert.ok(reasons.some(r => r.includes('no location coordinates')))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('속도를 아무도 선언하지 않으면 거리만으로 시간을 만들지 않는다', () => {
|
|
51
|
+
const { estimator, reasons } = buildTravelEstimator({ layout, equipment: [{ id: 'fk-1', kind: 'forklift' }], unit: 'm' })
|
|
52
|
+
assert.equal(estimator, undefined)
|
|
53
|
+
assert.ok(reasons.some(r => r.includes('speed')))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('좌표 단위가 미터가 아니면 환산을 추측하지 않는다', () => {
|
|
57
|
+
const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'ft' })
|
|
58
|
+
assert.equal(estimator, undefined)
|
|
59
|
+
assert.ok(reasons.some(r => r.includes('not metres')))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('같은 자리의 작업(처리·체류)은 다루지 않는다 — 거리 0 을 0 초로 만들면 공정 시간이 사라진다', () => {
|
|
63
|
+
const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
|
|
64
|
+
assert.equal(estimator!.estimate({ kind: 'cut', fromNode: 'rack', toNode: 'rack', resourceKind: 'forklift' }), undefined)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('좌표 모르는 노드·속도 모르는 자원종류는 넘긴다(부분 적용)', () => {
|
|
68
|
+
const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
|
|
69
|
+
assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack-99', resourceKind: 'forklift' }), undefined)
|
|
70
|
+
assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack', resourceKind: 'hostler' }), undefined)
|
|
71
|
+
assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack' }), undefined, '자원 종류 미지')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/* ── 실측(이력) ─────────────────────────────────────────────────────────── */
|
|
75
|
+
|
|
76
|
+
test('작업 종류별 작업시간 p50 을 소요로 배운다 — 리드타임(대기 포함)이 아니라 작업시간', () => {
|
|
77
|
+
const r = buildMeasuredEstimator([
|
|
78
|
+
{ key: 'putaway', workTime: { count: 40, p50Ms: 47_000 } },
|
|
79
|
+
{ key: 'pick', workTime: { count: 12, p50Ms: 22_500 } }
|
|
80
|
+
])
|
|
81
|
+
assert.deepEqual(r.learned, { putaway: 47_000, pick: 22_500 })
|
|
82
|
+
assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), { meanMs: 47_000 })
|
|
83
|
+
assert.equal(r.estimator!.estimate({ kind: 'unseen', fromNode: 'a', toNode: 'b' }), undefined, '못 배운 종류는 넘긴다')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/* ── 관측된 퍼짐 ────────────────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
test('두 꼬리가 있으면 퍼짐까지 배운다 — 평균만 배우면 줄이 생기지 않는다', () => {
|
|
89
|
+
const r = buildMeasuredEstimator([{ key: 'putaway', workTime: { count: 40, p10Ms: 30_000, p50Ms: 47_000, p90Ms: 80_000 } }])
|
|
90
|
+
assert.deepEqual(r.spreads, { putaway: { minMs: 30_000, maxMs: 80_000 } })
|
|
91
|
+
assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), {
|
|
92
|
+
meanMs: 47_000,
|
|
93
|
+
spread: { distribution: 'triangular', minMs: 30_000, maxMs: 80_000, modeMs: 47_000 }
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('한쪽 꼬리만 있으면 퍼짐을 만들지 않는다 — 아래쪽을 위쪽에서 베끼는 것은 발명이다', () => {
|
|
98
|
+
const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p50Ms: 20_000, p90Ms: 40_000 } }])
|
|
99
|
+
assert.deepEqual(r.spreads, {})
|
|
100
|
+
assert.deepEqual(r.estimator!.estimate({ kind: 'pick', fromNode: 'a', toNode: 'b' }), { meanMs: 20_000 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('구간이 성립하지 않으면 퍼짐 없이 평균만 — 없는 흔들림을 만들지 않는다', () => {
|
|
104
|
+
const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p10Ms: 50_000, p50Ms: 20_000, p90Ms: 20_000 } }])
|
|
105
|
+
assert.deepEqual(r.spreads, {}, 'p10 >= p90 이면 구간이 아니다')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('표본이 적으면 배우지 않고, 무엇을 버렸는지 밝힌다 — 두 건의 p50 은 현장 값이 아니다', () => {
|
|
109
|
+
const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 2, p50Ms: 9_000 } }])
|
|
110
|
+
assert.equal(r.estimator, undefined)
|
|
111
|
+
assert.deepEqual(r.skipped, [{ kind: 'pick', count: 2 }])
|
|
112
|
+
assert.equal(r.minSamples, DEFAULT_MIN_SAMPLES)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("'unknown' 축은 배우지 않는다 — 종류 모르는 기록을 특정 종류의 근거로 쓰지 않는다", () => {
|
|
116
|
+
const r = buildMeasuredEstimator([{ key: 'unknown', workTime: { count: 99, p50Ms: 30_000 } }])
|
|
117
|
+
assert.deepEqual(r.learned, {})
|
|
118
|
+
assert.equal(r.estimator, undefined)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', () => {
|
|
122
|
+
const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 50, p50Ms: 0 } }])
|
|
123
|
+
assert.deepEqual(r.learned, {})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('그룹이 없으면 빈 결과 — 던지지 않는다', () => {
|
|
127
|
+
assert.equal(buildMeasuredEstimator(undefined).estimator, undefined)
|
|
128
|
+
assert.equal(buildMeasuredEstimator([]).estimator, undefined)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/* ── 사슬 — 실측이 계산을 이긴다 ────────────────────────────────────────── */
|
|
132
|
+
|
|
133
|
+
test('실측이 먼저, 없으면 거리 계산 — 강한 근거가 앞이다', () => {
|
|
134
|
+
const measured = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 30, p50Ms: 11_000 } }])
|
|
135
|
+
const travel = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
|
|
136
|
+
const chained = chainEstimators([measured.estimator, travel.estimator])!
|
|
137
|
+
assert.deepEqual(chained.estimate({ kind: 'pick', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), { meanMs: 11_000 }, '실측 우선')
|
|
138
|
+
assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000, '실측 없으면 거리')
|
|
139
|
+
assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'dock', resourceKind: 'forklift' }), undefined, '둘 다 모르면 넘긴다')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('빈 사슬은 undefined — 주입하지 않는다(상수로 굴러가는 것이 정직하다)', () => {
|
|
143
|
+
assert.equal(chainEstimators([undefined, undefined]), undefined)
|
|
144
|
+
})
|
|
@@ -1,51 +1,182 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 호스트 방출 매핑(buildEntityDeltas) 단위 검증 — 씬 컴포넌트가 소비하는 data(tag) payload 계약.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* 지키는 것(2026-08-01 재편):
|
|
5
|
+
* ① **레퍼런스가 아는 것은 전부 나간다** — 필드를 손으로 골라 담지 않는다. 화이트리스트였을 때
|
|
6
|
+
* 노드 parentId·작업 전량·물품 전량이 조용히 누락됐다. 계약에 속성이 생기면 자동으로 도달해야 한다.
|
|
7
|
+
* ② **표식은 도메인 낱말을 훔치지 않는다** — 엔티티 종류 표식은 `entity`, `kind` 는 도메인 것
|
|
8
|
+
* (무버 cutter · 작업 putaway · 오더 workorder).
|
|
9
|
+
* ③ **조용히 자르지 않는다** — 물품 집약이 상한에 걸리면 total·truncated 로 밝힌다.
|
|
4
10
|
*/
|
|
5
11
|
import { test } from 'node:test'
|
|
6
12
|
import assert from 'node:assert/strict'
|
|
7
13
|
|
|
8
|
-
import { buildEntityDeltas, ORDERS_TAG } from '../server/engine/entity-delta.ts'
|
|
14
|
+
import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, ITEMS_TAG, ORDERS_TAG, PERSONS_TAG, TASKS_TAG } from '../server/engine/entity-delta.ts'
|
|
9
15
|
|
|
10
16
|
const byTag = (arr: any[], tag: string) => arr.find(x => x.tag === tag)
|
|
11
17
|
|
|
12
|
-
test('
|
|
13
|
-
const d = buildEntityDeltas({
|
|
14
|
-
|
|
18
|
+
test('노드: 계약의 속성이 전부 나간다 — parentId(소속 구역)를 포함해', () => {
|
|
19
|
+
const d = buildEntityDeltas({
|
|
20
|
+
locations: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage', parentId: 'zone-a' }]
|
|
21
|
+
})
|
|
22
|
+
assert.deepEqual(byTag(d, 'bin-1').data, {
|
|
23
|
+
entity: 'location',
|
|
24
|
+
occupancy: 3,
|
|
25
|
+
capacity: 10,
|
|
26
|
+
status: 'idle',
|
|
27
|
+
type: 'storage',
|
|
28
|
+
parentId: 'zone-a'
|
|
29
|
+
})
|
|
15
30
|
})
|
|
16
31
|
|
|
17
|
-
test('
|
|
32
|
+
test('표식은 entity, kind 는 도메인 것 — 무버 종류가 표식에 덮이지 않는다', () => {
|
|
18
33
|
const d = buildEntityDeltas({
|
|
19
|
-
|
|
34
|
+
equipment: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true }]
|
|
20
35
|
})
|
|
21
36
|
const m = byTag(d, 'cutter-1').data
|
|
22
|
-
assert.equal(m.
|
|
37
|
+
assert.equal(m.entity, 'equipment', '표식')
|
|
38
|
+
assert.equal(m.kind, 'cutter', '도메인 속성 — 예전엔 표식에 덮여 사라졌다')
|
|
23
39
|
assert.equal(m.status, 'down')
|
|
24
40
|
assert.equal(m.held, true)
|
|
25
|
-
// 정수 퍼센트 + 카운트만(원시 누적 ms 는 제외 — churn 억제)
|
|
26
|
-
assert.deepEqual(m.oee, { overall: 82, availability: 90, performance: 95, quality: 96, good: 12, scrap: 3 })
|
|
27
41
|
})
|
|
28
42
|
|
|
29
|
-
test('
|
|
30
|
-
const d = buildEntityDeltas({
|
|
43
|
+
test('무버: OEE 는 정수 퍼센트로 정형화하고 원시 누적은 빼낸다(churn 억제)', () => {
|
|
44
|
+
const d = buildEntityDeltas({
|
|
45
|
+
equipment: [
|
|
46
|
+
{
|
|
47
|
+
id: 'cutter-1',
|
|
48
|
+
kind: 'cutter',
|
|
49
|
+
status: 'down',
|
|
50
|
+
oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, scrapCount: 3, idleMs: 99999 }
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
})
|
|
54
|
+
assert.deepEqual(byTag(d, 'cutter-1').data.oee, {
|
|
55
|
+
overall: 82,
|
|
56
|
+
availability: 90,
|
|
57
|
+
performance: 95,
|
|
58
|
+
quality: 96,
|
|
59
|
+
good: 12,
|
|
60
|
+
scrap: 3
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('무버: oee 미측정이면 실리지 않는다', () => {
|
|
65
|
+
const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] })
|
|
31
66
|
assert.equal(byTag(d, 'fk-1').data.oee, undefined)
|
|
32
67
|
})
|
|
33
68
|
|
|
34
|
-
test('
|
|
69
|
+
test('작업: 개별 + 집약으로 나간다 — 예전엔 아예 방출되지 않았다', () => {
|
|
70
|
+
const d = buildEntityDeltas({
|
|
71
|
+
tasks: [
|
|
72
|
+
{ id: 't1', kind: 'putaway', status: 'in-progress', fromNode: 'dock', toNode: 'rack-1', resourceRef: 'fk-1', orderId: 'o1', progress: 0.4 },
|
|
73
|
+
{ id: 't2', kind: 'pick', status: 'created' }
|
|
74
|
+
]
|
|
75
|
+
})
|
|
76
|
+
assert.deepEqual(byTag(d, 't1').data, {
|
|
77
|
+
entity: 'task',
|
|
78
|
+
kind: 'putaway',
|
|
79
|
+
status: 'in-progress',
|
|
80
|
+
fromNode: 'dock',
|
|
81
|
+
toNode: 'rack-1',
|
|
82
|
+
resourceRef: 'fk-1',
|
|
83
|
+
orderId: 'o1',
|
|
84
|
+
progress: 0.4
|
|
85
|
+
})
|
|
86
|
+
const agg = byTag(d, TASKS_TAG).data
|
|
87
|
+
assert.equal(agg.entity, 'tasks')
|
|
88
|
+
assert.equal(agg.tasks.length, 2)
|
|
89
|
+
assert.equal(agg.tasks[0].id, 't1', '집약에는 식별자가 있다')
|
|
90
|
+
assert.equal(agg.tasks[0].resourceRef, 'fk-1', '집약도 속성을 다 담는다')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('물품: 개별(태그=epc) + 집약으로 나간다 — 수량·소속 팔레트·만료까지', () => {
|
|
94
|
+
const d = buildEntityDeltas({
|
|
95
|
+
items: [{ epc: 'urn:epc:id:sgtin:1.1.1', gtin: 'g1', location: 'rack-1', disposition: 'sellable', parent: 'sscc-1', qty: 40, expiry: 1234 }]
|
|
96
|
+
})
|
|
97
|
+
assert.deepEqual(byTag(d, 'urn:epc:id:sgtin:1.1.1').data, {
|
|
98
|
+
entity: 'item',
|
|
99
|
+
gtin: 'g1',
|
|
100
|
+
location: 'rack-1',
|
|
101
|
+
disposition: 'sellable',
|
|
102
|
+
parent: 'sscc-1',
|
|
103
|
+
qty: 40,
|
|
104
|
+
expiry: 1234
|
|
105
|
+
})
|
|
106
|
+
const agg = byTag(d, ITEMS_TAG).data
|
|
107
|
+
assert.equal(agg.entity, 'items')
|
|
108
|
+
assert.equal(agg.items[0].epc, 'urn:epc:id:sgtin:1.1.1')
|
|
109
|
+
assert.equal(agg.items[0].qty, 40)
|
|
110
|
+
assert.equal(agg.total, 1)
|
|
111
|
+
assert.equal(agg.truncated, undefined, '상한 안이면 truncated 없음')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 조용한 절단 금지', () => {
|
|
115
|
+
const items = Array.from({ length: ITEMS_AGGREGATE_LIMIT + 7 }, (_, i) => ({ epc: `e${i}`, location: 'rack-1' }))
|
|
116
|
+
const d = buildEntityDeltas({ items })
|
|
117
|
+
const agg = byTag(d, ITEMS_TAG).data
|
|
118
|
+
assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT)
|
|
119
|
+
assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7)
|
|
120
|
+
assert.equal(agg.truncated, 7)
|
|
121
|
+
/* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재방송한다). */
|
|
122
|
+
assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('오더: 개별 + 집약, 도메인 kind 유지', () => {
|
|
35
126
|
const d = buildEntityDeltas({
|
|
36
127
|
orders: [
|
|
37
128
|
{ id: 'o1', kind: 'workorder', status: 'picking', progress: 0.5, held: false },
|
|
38
|
-
{ id: 'o2', kind: '
|
|
129
|
+
{ id: 'o2', kind: 'outbound', status: 'created', progress: 0, held: true }
|
|
39
130
|
]
|
|
40
131
|
})
|
|
41
|
-
assert.
|
|
132
|
+
assert.equal(byTag(d, 'o1').data.kind, 'workorder')
|
|
42
133
|
const agg = byTag(d, ORDERS_TAG).data
|
|
43
|
-
assert.equal(agg.
|
|
134
|
+
assert.equal(agg.entity, 'orders')
|
|
44
135
|
assert.equal(agg.orders.length, 2)
|
|
45
136
|
assert.equal(agg.orders[1].held, true, '집약에 held 반영')
|
|
46
137
|
})
|
|
47
138
|
|
|
48
|
-
test('
|
|
49
|
-
|
|
50
|
-
|
|
139
|
+
test('계약에 새 속성이 생기면 손대지 않아도 도달한다 — 화이트리스트 재발 방지', () => {
|
|
140
|
+
/* 이 테스트가 실패하면 누군가 필드를 손으로 골라 담기 시작한 것이다. */
|
|
141
|
+
const d = buildEntityDeltas({ locations: [{ id: 'n1', occupancy: 0, temperatureC: -18, hazmatClass: '3' }] })
|
|
142
|
+
const n = byTag(d, 'n1').data
|
|
143
|
+
assert.equal(n.temperatureC, -18, '알지 못하는 새 속성도 통과해야 한다')
|
|
144
|
+
assert.equal(n.hazmatClass, '3')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('undefined 값은 싣지 않는다 — 키 유무가 시그니처를 흔들지 않게', () => {
|
|
148
|
+
const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', location: undefined, status: 'idle' }] })
|
|
149
|
+
assert.equal('location' in byTag(d, 'fk-1').data, false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('빈 상태 — 집약 태그 셋은 항상 방출(빈 배열)', () => {
|
|
153
|
+
const d = buildEntityDeltas({})
|
|
154
|
+
assert.deepEqual(byTag(d, ORDERS_TAG).data.orders, [])
|
|
155
|
+
assert.deepEqual(byTag(d, TASKS_TAG).data.tasks, [])
|
|
156
|
+
assert.deepEqual(byTag(d, ITEMS_TAG).data.items, [])
|
|
157
|
+
assert.equal(byTag(d, ITEMS_TAG).data.total, 0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/* ── 사람 ────────────────────────────────────────────────────────────────
|
|
161
|
+
*
|
|
162
|
+
* 인원은 설비와 별개 엔티티다(고장·설비효율이 아니라 등급·교대로 산다). 같은 통과 규칙을 쓰되
|
|
163
|
+
* 자기 태그로 나가고, 집약 태그도 따로 둔다 — 인원을 한눈에 보는 소비처가 있기 때문이다.
|
|
164
|
+
*/
|
|
165
|
+
test('사람이 개별 태그로 나간다 — 등급·교대가 그대로', () => {
|
|
166
|
+
const deltas = buildEntityDeltas({
|
|
167
|
+
persons: [{ id: 'w1', personnelClass: 'welder-op', status: 'busy', taskId: 't1', offShift: undefined }]
|
|
168
|
+
})
|
|
169
|
+
const one = deltas.find(d => d.tag === 'w1')!
|
|
170
|
+
assert.equal(one.data.entity, 'person')
|
|
171
|
+
assert.equal(one.data.personnelClass, 'welder-op')
|
|
172
|
+
assert.equal(one.data.status, 'busy')
|
|
173
|
+
assert.equal(one.data.taskId, 't1')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('사람 집약 태그가 항상 나간다 — 선언하지 않은 트윈은 빈 배열(없음과 모름을 구별)', () => {
|
|
177
|
+
const withNone = buildEntityDeltas({})
|
|
178
|
+
const agg = withNone.find(d => d.tag === PERSONS_TAG)!
|
|
179
|
+
assert.deepEqual(agg.data.persons, [])
|
|
180
|
+
const withTwo = buildEntityDeltas({ persons: [{ id: 'w1' }, { id: 'w2' }] })
|
|
181
|
+
assert.equal(withTwo.find(d => d.tag === PERSONS_TAG)!.data.persons.length, 2)
|
|
51
182
|
})
|
|
@@ -12,11 +12,11 @@ import { buildEntityDeltas } from '../server/engine/entity-delta.ts'
|
|
|
12
12
|
|
|
13
13
|
const T0 = '2026-07-19T00:00:00.000Z'
|
|
14
14
|
const board = (nNodes: number) => ({
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
locations: Array.from({ length: nNodes }, (_, i) => ({ id: `bin-${i}`, type: 'storage', capacity: 100000 })),
|
|
16
|
+
equipment: []
|
|
17
17
|
})
|
|
18
|
-
function envelope(epc: string,
|
|
19
|
-
const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint:
|
|
18
|
+
function envelope(epc: string, location: string): any {
|
|
19
|
+
const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint: location })
|
|
20
20
|
return { eventId: 'x', eventType: `epcis.${ev.type}`, eventTime: T0, tenantId: 't', data: ev }
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -52,18 +52,18 @@ test('bench: buildEntityDeltas 비용 vs 엔티티 수 (전 엔티티 매번 방
|
|
|
52
52
|
const t0 = performance.now()
|
|
53
53
|
for (let k = 0; k < iters; k++) buildEntityDeltas(snap)
|
|
54
54
|
const per = (performance.now() - t0) / iters
|
|
55
|
-
console.log(` [buildEntityDeltas] @ ${N.toLocaleString()}
|
|
55
|
+
console.log(` [buildEntityDeltas] @ ${N.toLocaleString()} locations: ${per.toFixed(3)}ms/call`)
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
|
|
60
|
-
for (const [
|
|
61
|
-
const proj = new StateProjector(board(
|
|
62
|
-
for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i %
|
|
60
|
+
for (const [locations, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
|
|
61
|
+
const proj = new StateProjector(board(locations))
|
|
62
|
+
for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % locations}`))
|
|
63
63
|
const iters = 100
|
|
64
64
|
const t0 = performance.now()
|
|
65
65
|
for (let k = 0; k < iters; k++) buildEntityDeltas(proj.snapshot())
|
|
66
66
|
const per = (performance.now() - t0) / iters
|
|
67
|
-
console.log(` [방송1회] ${
|
|
67
|
+
console.log(` [방송1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
|
|
68
68
|
}
|
|
69
69
|
})
|