@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
package/test/kpi-fold.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { test } from 'node:test'
|
|
|
11
11
|
import assert from 'node:assert/strict'
|
|
12
12
|
|
|
13
13
|
import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts'
|
|
14
|
+
import { judgeTargets, judgeGroupTargets, mergeTargets, scopedTargets } from '../server/engine/kpi-target.ts'
|
|
14
15
|
|
|
15
16
|
const T0 = Date.parse('2026-07-30T00:00:00Z')
|
|
16
17
|
const at = (sec: number) => new Date(T0 + sec * 1000).toISOString()
|
|
@@ -286,7 +287,7 @@ test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이
|
|
|
286
287
|
...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
|
|
287
288
|
]
|
|
288
289
|
const whole = foldKpi(events, WINDOW)
|
|
289
|
-
for (const by of ['resource', 'taskKind', '
|
|
290
|
+
for (const by of ['resource', 'taskKind', 'location', 'order'] as const) {
|
|
290
291
|
const r = foldKpi(events, WINDOW, { groupBy: by })
|
|
291
292
|
const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
|
|
292
293
|
assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
|
|
@@ -318,7 +319,7 @@ test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값
|
|
|
318
319
|
taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
|
|
319
320
|
taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
|
|
320
321
|
]
|
|
321
|
-
const r = foldKpi(events, WINDOW, { groupBy: '
|
|
322
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'location' })
|
|
322
323
|
assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
|
|
323
324
|
})
|
|
324
325
|
|
|
@@ -327,7 +328,7 @@ test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없
|
|
|
327
328
|
const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
|
|
328
329
|
assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
|
|
329
330
|
|
|
330
|
-
const mapped = foldKpi(events, WINDOW, { groupBy: 'area',
|
|
331
|
+
const mapped = foldKpi(events, WINDOW, { groupBy: 'area', locationArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
|
|
331
332
|
assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
|
|
332
333
|
})
|
|
333
334
|
|
|
@@ -467,3 +468,231 @@ test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어
|
|
|
467
468
|
assert.equal(c.items.every(x => x.isNew === true), true)
|
|
468
469
|
assert.deepEqual(c.disappeared, [])
|
|
469
470
|
})
|
|
471
|
+
|
|
472
|
+
/* ── 교대 축 ─────────────────────────────────────────────────────────────────
|
|
473
|
+
*
|
|
474
|
+
* 3교대처럼 하루를 끊김 없이 덮는 현장에서는 캘린더가 가용 시간을 줄이지 않는다. 그러면 교대 선언이
|
|
475
|
+
* 무엇을 주는가: **"언제" 를 묻는 축**이다. "어느 교대가 느린가" 는 3교대 공장에서 가장 먼저 나오는
|
|
476
|
+
* 질문인데, 그 축이 없으면 답이 없었다.
|
|
477
|
+
*
|
|
478
|
+
* **저널에는 교대가 없다 — 계산한다.** 이벤트에 교대 이름을 찍어 두면 나중에 교대 시간을 고쳤을 때
|
|
479
|
+
* **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 완료 시각 + 현장의 시각 기준 + 교대 선언에서 낸다.
|
|
480
|
+
*/
|
|
481
|
+
const SHIFTS_3 = [
|
|
482
|
+
{ id: 'A', fromTime: '06:00', toTime: '14:00' },
|
|
483
|
+
{ id: 'B', fromTime: '14:00', toTime: '22:00' },
|
|
484
|
+
{ id: 'C', fromTime: '22:00', toTime: '06:00', toDayOffset: 1 as const }
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
/** 하루치 창 — 세 교대가 모두 들어오게. */
|
|
488
|
+
const DAY = { fromMs: T0, toMs: T0 + 24 * 3600 * 1000 }
|
|
489
|
+
|
|
490
|
+
/** `hour` 시(UTC)에 완료되는 작업 한 건. */
|
|
491
|
+
const doneAtHour = (id: string, hour: number): KpiEvent[] => [
|
|
492
|
+
task(id, 'created', hour * 3600 - 60),
|
|
493
|
+
task(id, 'in-progress', hour * 3600 - 30),
|
|
494
|
+
task(id, 'completed', hour * 3600)
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
test('교대별로 쪼갠다 — 완료 시각에서 계산한다(저널에 없는 축)', () => {
|
|
498
|
+
const r = foldKpi(
|
|
499
|
+
[...doneAtHour('a1', 7), ...doneAtHour('a2', 9), ...doneAtHour('b1', 15), ...doneAtHour('c1', 23)],
|
|
500
|
+
DAY,
|
|
501
|
+
{ groupBy: 'shift', shifts: SHIFTS_3 }
|
|
502
|
+
)
|
|
503
|
+
const by = Object.fromEntries((r.groups?.items ?? []).map(g => [g.key, g.tasks]))
|
|
504
|
+
assert.deepEqual(by, { A: 2, B: 1, C: 1 })
|
|
505
|
+
assert.equal(r.groups?.items.reduce((s, g) => s + g.tasks, 0), r.throughput.tasks, '축의 합이 전체와 같다')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('현장의 시각 기준으로 읽는다 — UTC 로 조용히 가정하지 않는다', () => {
|
|
509
|
+
const events = [...doneAtHour('t1', 20)] // 절대 20시
|
|
510
|
+
const utc = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 })
|
|
511
|
+
const local = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3, utcOffsetMinutes: -420 })
|
|
512
|
+
assert.equal(utc.groups?.items[0].key, 'B', 'UTC 20시는 B 교대')
|
|
513
|
+
assert.equal(local.groups?.items[0].key, 'A', 'Rosarito(UTC−7) 기준으로는 13시 — A 교대다')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('교대를 선언하지 않은 현장은 unknown — 없는 구분을 지어내지 않는다', () => {
|
|
517
|
+
const r = foldKpi([...doneAtHour('t1', 7)], DAY, { groupBy: 'shift' })
|
|
518
|
+
assert.deepEqual((r.groups?.items ?? []).map(g => g.key), ['unknown'])
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test('교대 정의를 고치면 과거가 다시 세어진다 — 저널에 찍어 뒀다면 굳었을 것', () => {
|
|
522
|
+
const events = [...doneAtHour('t1', 7)]
|
|
523
|
+
const twoShift = [
|
|
524
|
+
{ id: 'DAY', fromTime: '07:00', toTime: '19:00' },
|
|
525
|
+
{ id: 'NIGHT', fromTime: '19:00', toTime: '07:00', toDayOffset: 1 as const }
|
|
526
|
+
]
|
|
527
|
+
assert.equal(foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }).groups?.items[0].key, 'A')
|
|
528
|
+
assert.equal(
|
|
529
|
+
foldKpi(events, DAY, { groupBy: 'shift', shifts: twoShift }).groups?.items[0].key,
|
|
530
|
+
'DAY',
|
|
531
|
+
'같은 저널, 다른 교대 정의 → 다시 센다'
|
|
532
|
+
)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
/* ── 목표 대비 판정 ───────────────────────────────────────────────────────────
|
|
536
|
+
*
|
|
537
|
+
* 화면과 AI 는 "처리량 345건" 을 정확히 말하지만 **그것이 좋은지 나쁜지는 말할 수 없었다.** 근거가 앞
|
|
538
|
+
* 구간 비교뿐이라 "어제보다 나아졌다" 가 한계이고, 어제도 나빴으면 쓸모가 없다.
|
|
539
|
+
*
|
|
540
|
+
* 판정은 **사실 계산과 섞지 않는다** — 폴드는 목표를 모르고, 판정은 순수 함수로 따로 있다.
|
|
541
|
+
*/
|
|
542
|
+
const kpiOf = (events: KpiEvent[]) => foldKpi(events, DAY)
|
|
543
|
+
|
|
544
|
+
test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없다', () => {
|
|
545
|
+
const kpi = kpiOf([...doneAtHour('t1', 7), ...doneAtHour('t2', 8)])
|
|
546
|
+
const [more] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 2 }])
|
|
547
|
+
assert.equal(more.direction, 'higher')
|
|
548
|
+
assert.equal(more.met, true, '처리량은 목표 이상이면 만족')
|
|
549
|
+
|
|
550
|
+
const [faster] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 60_000 }])
|
|
551
|
+
assert.equal(faster.direction, 'lower', '시간은 낮을수록 좋다 — 입력이 아니라 지표가 안다')
|
|
552
|
+
assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초')
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
test('미달을 미달이라 말한다', () => {
|
|
556
|
+
const kpi = kpiOf([...doneAtHour('t1', 7)])
|
|
557
|
+
const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }])
|
|
558
|
+
assert.equal(v.met, false)
|
|
559
|
+
assert.equal(v.actual, 1)
|
|
560
|
+
assert.equal(v.ratio, 0.2, '비율은 항상 "1 이상이면 좋다" 로 정규화된다')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('비율은 방향이 달라도 같은 뜻이다 — 한 화면에서 비교할 수 있게', () => {
|
|
564
|
+
const kpi = kpiOf([...doneAtHour('t1', 7)])
|
|
565
|
+
const [slow] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 15_000 }]) // 실제 30초, 목표 15초
|
|
566
|
+
assert.equal(slow.met, false)
|
|
567
|
+
assert.equal(slow.ratio, 0.5, '두 배 느리면 0.5 — 처리량이 절반일 때와 같은 숫자')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('목표가 없으면 판정하지 않는다', () => {
|
|
571
|
+
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), []), [])
|
|
572
|
+
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), [])
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 반대로 거짓말한다', () => {
|
|
576
|
+
const empty = foldKpi([], DAY)
|
|
577
|
+
/* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면
|
|
578
|
+
시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 —
|
|
579
|
+
미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). */
|
|
580
|
+
assert.deepEqual(judgeTargets(empty, [{ metric: 'workTime.p50Ms', target: 15_000 }]), [])
|
|
581
|
+
assert.deepEqual(judgeTargets(empty, [{ metric: 'throughput.tasks', target: 10 }]), [], '기록 없음 ≠ 처리량 0')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('모르는 지표 이름은 짐작해 매핑하지 않는다', () => {
|
|
585
|
+
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), [{ metric: 'cycleTime.p50' as never, target: 1 }]), [])
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
/* ── 목표의 범위 (공간 vs 인스턴스) ──────────────────────────────────────────
|
|
589
|
+
*
|
|
590
|
+
* 이 규칙은 원래 DB 접근 뒤에 숨어 있어 **화면을 눌러 봐야만** 확인할 수 있었다. 로그인 뒤 화면을
|
|
591
|
+
* 자동으로 검증할 방법이 없는 프로젝트라 그런 규칙은 조용히 미검증으로 쌓인다 — 그래서 밖으로 뺐다.
|
|
592
|
+
*/
|
|
593
|
+
const row = (metric: string, target: number, scope: { spaceId?: string; instanceId?: string }) => ({
|
|
594
|
+
metric,
|
|
595
|
+
target,
|
|
596
|
+
spaceId: scope.spaceId ?? null,
|
|
597
|
+
instanceId: scope.instanceId ?? null
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('공간 목표가 그 공간의 모든 트윈에 걸린다', () => {
|
|
601
|
+
const got = mergeTargets([row('throughput.tasks', 100, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: ['t1', 't2'] })
|
|
602
|
+
assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 100 }])
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test('인스턴스 목표가 공간 목표를 덮는다 — 예외를 표현할 수 있어야 한다', () => {
|
|
606
|
+
const got = mergeTargets(
|
|
607
|
+
[row('throughput.tasks', 100, { spaceId: 'site-a' }), row('throughput.tasks', 40, { instanceId: 't2' })],
|
|
608
|
+
{ spaceId: 'site-a', instanceIds: ['t2'] }
|
|
609
|
+
)
|
|
610
|
+
assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 40 }], '"이 라인만 다르다" 를 말할 수 있어야 한다')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('다른 공간·다른 인스턴스의 목표는 새어 들어오지 않는다', () => {
|
|
614
|
+
const got = mergeTargets(
|
|
615
|
+
[row('throughput.tasks', 999, { spaceId: 'site-b' }), row('workTime.p50Ms', 999, { instanceId: 'other' })],
|
|
616
|
+
{ spaceId: 'site-a', instanceIds: ['t1'] }
|
|
617
|
+
)
|
|
618
|
+
assert.deepEqual(got, [])
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test('모르는 지표 이름·숫자 아닌 값은 목표로 세지 않는다', () => {
|
|
622
|
+
const got = mergeTargets(
|
|
623
|
+
[row('cycleTime.p50', 10, { spaceId: 'site-a' }), row('throughput.tasks', Number.NaN, { spaceId: 'site-a' })],
|
|
624
|
+
{ spaceId: 'site-a', instanceIds: [] }
|
|
625
|
+
)
|
|
626
|
+
assert.deepEqual(got, [], '평가할 수 없는 목표는 "목표 없음" 으로 읽혀 갭을 감춘다 — 아예 세지 않는다')
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
test('공간을 안 고르고 트윈 하나만 볼 때도 그 인스턴스 목표는 걸린다', () => {
|
|
630
|
+
const got = mergeTargets([row('waitTime.p50Ms', 5000, { instanceId: 't1' })], { instanceIds: ['t1'] })
|
|
631
|
+
assert.deepEqual(got, [{ metric: 'waitTime.p50Ms', target: 5000 }])
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
/* ── 축별 목표 ───────────────────────────────────────────────────────────────
|
|
635
|
+
*
|
|
636
|
+
* 구역·교대마다 목표가 다를 수 있다("이 라인만 40건"). 그런데 축 목표는 **전체 목표를 덮지 않는다** —
|
|
637
|
+
* 다른 질문이기 때문이다: "현장 전체 100건" 과 "용접 구역 30건" 은 동시에 참일 수 있다. 덮게 만들면
|
|
638
|
+
* 축 하나에 목표를 거는 순간 전체 판정이 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
|
|
639
|
+
*/
|
|
640
|
+
const arow = (metric: string, target: number, extra: Record<string, unknown> = {}) => ({
|
|
641
|
+
metric,
|
|
642
|
+
target,
|
|
643
|
+
spaceId: 'site-a',
|
|
644
|
+
instanceId: null,
|
|
645
|
+
axis: null,
|
|
646
|
+
axisKey: null,
|
|
647
|
+
...extra
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
test('축 목표와 전체 목표가 함께 산다 — 덮지 않는다', () => {
|
|
651
|
+
const got = scopedTargets(
|
|
652
|
+
[arow('throughput.tasks', 100), arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })],
|
|
653
|
+
{ spaceId: 'site-a', instanceIds: [], axis: 'area' }
|
|
654
|
+
)
|
|
655
|
+
assert.deepEqual(got.overall, [{ metric: 'throughput.tasks', target: 100 }], '전체 목표는 그대로 있다')
|
|
656
|
+
assert.deepEqual(got.byAxisKey.weld, [{ metric: 'throughput.tasks', target: 30 }])
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
test('지금 보는 축의 목표만 온다 — 다른 축의 목표가 섞이지 않는다', () => {
|
|
660
|
+
const got = scopedTargets(
|
|
661
|
+
[arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' }), arow('throughput.tasks', 50, { axis: 'shift', axisKey: 'A' })],
|
|
662
|
+
{ spaceId: 'site-a', instanceIds: [], axis: 'shift' }
|
|
663
|
+
)
|
|
664
|
+
assert.deepEqual(Object.keys(got.byAxisKey), ['A'])
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
test('축을 안 보고 있으면 축 목표는 오지 않는다 — 전체만', () => {
|
|
668
|
+
const got = scopedTargets([arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], {
|
|
669
|
+
spaceId: 'site-a',
|
|
670
|
+
instanceIds: []
|
|
671
|
+
})
|
|
672
|
+
assert.deepEqual(got.byAxisKey, {})
|
|
673
|
+
assert.deepEqual(got.overall, [])
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않는다', () => {
|
|
677
|
+
const group = {
|
|
678
|
+
key: 'weld',
|
|
679
|
+
tasks: 12,
|
|
680
|
+
leadTime: { count: 12, p50Ms: 90_000 },
|
|
681
|
+
workTime: { count: 12, p50Ms: 30_000 },
|
|
682
|
+
waitTime: { count: 0, p50Ms: 0 }
|
|
683
|
+
}
|
|
684
|
+
const v = judgeGroupTargets(group, [
|
|
685
|
+
{ metric: 'throughput.tasks', target: 10 },
|
|
686
|
+
{ metric: 'workTime.p50Ms', target: 20_000 },
|
|
687
|
+
{ metric: 'waitTime.p50Ms', target: 5_000 },
|
|
688
|
+
{ metric: 'utilization.ratio', target: 0.8 }
|
|
689
|
+
])
|
|
690
|
+
assert.deepEqual(
|
|
691
|
+
v.map(x => [x.metric, x.met]),
|
|
692
|
+
[
|
|
693
|
+
['throughput.tasks', true],
|
|
694
|
+
['workTime.p50Ms', false]
|
|
695
|
+
],
|
|
696
|
+
'대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함'
|
|
697
|
+
)
|
|
698
|
+
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 결론(검증됨):
|
|
6
6
|
* - 핵심 관측 상태(점유·용량·위치·아이템 = "사진에 찍힌 것")는 이벤트만으로 재구성 → sim↔live **완전 일치**.
|
|
7
|
-
* - 파생 지표(
|
|
7
|
+
* - 파생 지표(설비 OEE·location status = "계산해서 만든 통계")는 이벤트에 없음 → live 미러엔 **부재**.
|
|
8
8
|
* 이는 라우팅 결함이 아니라(통로는 온전), live 에서 파생 지표를 **누가 생산하나**의 데이터원 결정 사항.
|
|
9
9
|
* (실 텔레메트리 / projector 계산 / 별도 분석 층 — face2-inbound-live §6, reference-management P3.)
|
|
10
10
|
*/
|
|
@@ -25,11 +25,11 @@ const master = () => ({
|
|
|
25
25
|
space: { width: 90, depth: 40, unit: 'm' },
|
|
26
26
|
locations: [
|
|
27
27
|
{ id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
|
|
28
|
-
{ id: 'dock-recv', type: 'dock', role: '
|
|
29
|
-
{ id: 'rack-1', type: 'storage', role: '
|
|
30
|
-
{ id: 'rack-2', type: 'storage', role: '
|
|
31
|
-
{ id: 'staging', type: 'staging', role: '
|
|
32
|
-
{ id: 'dock-ship', type: 'dock-ship', role: '
|
|
28
|
+
{ id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
|
|
29
|
+
{ id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 },
|
|
30
|
+
{ id: 'rack-2', type: 'storage', role: 'location' as const, capacity: 60 },
|
|
31
|
+
{ id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
|
|
32
|
+
{ id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
|
|
33
33
|
],
|
|
34
34
|
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }]
|
|
35
35
|
})
|
|
@@ -52,11 +52,21 @@ function runSim() {
|
|
|
52
52
|
const sortByTag = (arr: any[]) => [...arr].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
|
|
53
53
|
|
|
54
54
|
/** 파생/sim-계산 필드 제거 → "핵심 관측 상태"만 남긴다.
|
|
55
|
-
*
|
|
55
|
+
* location.status = 커널 기본값(파생), equipment.oee = 커널 계산 지표(파생). 이벤트에 없어 live 미러 부재. */
|
|
56
|
+
/**
|
|
57
|
+
* 설계상 두 구동이 다른 자리만 걷어낸다 — **여기 없는 차이는 결함이다.**
|
|
58
|
+
*
|
|
59
|
+
* 엔티티 마커가 `kind` → `entity` 로 바뀌었을 때(도메인 kind 를 마커가 덮어쓰던 문제) 이 필터가
|
|
60
|
+
* 따라가지 못해 **아무것도 걸러내지 못하고 있었다.** 그래서 실패가 "정합성 결함" 처럼 보였다.
|
|
61
|
+
*
|
|
62
|
+
* 지금 남는 차이는 하나뿐이다:
|
|
63
|
+
* · `oee` — 시뮬은 자기가 계측하고, 라이브는 **호스트**가 이벤트를 적분해 채운다(withLiveOee).
|
|
64
|
+
* 커널 층에서만 비대칭이고 호스트가 닫는다(커널 적합성 하네스도 같은 예외를 선언한다).
|
|
65
|
+
* (`location.status` 는 예외였다가 해소됐다 — 저장값이 아니라 포화도 파생이라 두 구동이 같은 값을 낸다.)
|
|
66
|
+
*/
|
|
56
67
|
const coreOnly = (d: any) => {
|
|
57
68
|
const c = { ...d }
|
|
58
|
-
if (c.
|
|
59
|
-
if (c.kind === 'mover') delete c.oee
|
|
69
|
+
if (c.entity === 'equipment') delete c.oee
|
|
60
70
|
return c
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -92,10 +102,10 @@ function runSimRich() {
|
|
|
92
102
|
space: { width: 90, depth: 40, unit: 'm' },
|
|
93
103
|
locations: [
|
|
94
104
|
{ id: 'a', type: 'warehouse', role: 'area' as const, name: '창고' },
|
|
95
|
-
{ id: 'dock-recv', type: 'dock', role: '
|
|
96
|
-
{ id: 'rack-1', type: 'storage', role: '
|
|
97
|
-
{ id: 'staging', type: 'staging', role: '
|
|
98
|
-
{ id: 'dock-ship', type: 'dock-ship', role: '
|
|
105
|
+
{ id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
|
|
106
|
+
{ id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 6 }, // 소용량 → 병목
|
|
107
|
+
{ id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
|
|
108
|
+
{ id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
|
|
99
109
|
],
|
|
100
110
|
equipment: [
|
|
101
111
|
{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }, // 고장
|
|
@@ -127,9 +137,9 @@ test('명시: 파생 층(attentions·OEE·motion·clock)은 live 미러 부재/
|
|
|
127
137
|
assert.equal(sim.attentions.length > 0 && (live.attentions?.length ?? 0), 0, 'live: attentions 부재 — computeAttentions 를 미러 State 에 재적용해야 복원')
|
|
128
138
|
|
|
129
139
|
// 2) OEE(계산 지표) — 시간 누적. live 부재.
|
|
130
|
-
const simFk = sim.
|
|
131
|
-
const liveFk = live.
|
|
132
|
-
assert.ok(simFk?.oee, 'sim:
|
|
140
|
+
const simFk = sim.equipment.find((x: any) => x.id === 'fk-1')
|
|
141
|
+
const liveFk = live.equipment.find((x: any) => x.id === 'fk-1')
|
|
142
|
+
assert.ok(simFk?.oee, 'sim: 설비 OEE 계산됨')
|
|
133
143
|
assert.equal(liveFk?.oee, undefined, 'live: OEE 부재(시간 누적 지표)')
|
|
134
144
|
|
|
135
145
|
// 3) simClockMs(sim 클럭) — live 는 실시간이 클럭원.
|
|
@@ -150,7 +160,7 @@ test('★ derive 층: computeAttentions 를 projector 미러에 재적용 →
|
|
|
150
160
|
const { sim, live } = runSimRich()
|
|
151
161
|
|
|
152
162
|
// (a) 순수 함수가 커널 자체 attentions 를 재현 — 추출 정확성(sim state 로 호출 == 커널 계산 관측기반).
|
|
153
|
-
const simDerivedKinds = new Set(deriveAttentions({
|
|
163
|
+
const simDerivedKinds = new Set(deriveAttentions({ equipment: sim.equipment, locations: sim.locations, orders: sim.orders }).map(a => a.kind))
|
|
154
164
|
for (const a of sim.attentions ?? []) {
|
|
155
165
|
if (['breakdown', 'bottleneck', 'hold'].includes(a.kind)) {
|
|
156
166
|
assert.ok(simDerivedKinds.has(a.kind), `순수 함수가 sim 관측기반 attention '${a.kind}' 재현`)
|
|
@@ -159,7 +169,7 @@ test('★ derive 층: computeAttentions 를 projector 미러에 재적용 →
|
|
|
159
169
|
|
|
160
170
|
// (b) ★ projector 미러 State 에 재적용 → live 원본엔 없던 attentions 복원.
|
|
161
171
|
assert.equal(live.attentions?.length ?? 0, 0, 'live projector 원본엔 attentions 없음(계산 안 함)')
|
|
162
|
-
const liveKinds = new Set(deriveAttentions({
|
|
172
|
+
const liveKinds = new Set(deriveAttentions({ equipment: live.equipment, locations: live.locations, orders: live.orders }).map(a => a.kind))
|
|
163
173
|
assert.ok(liveKinds.has('breakdown') || liveKinds.has('bottleneck'),
|
|
164
174
|
'derive 재적용 → live 에 관측기반 attention(breakdown/bottleneck) 복원')
|
|
165
175
|
})
|
|
@@ -181,7 +191,7 @@ test('★ withLiveAttentions: projector 미러 스냅샷에 attentions 보강 (a
|
|
|
181
191
|
*/
|
|
182
192
|
test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE 재생산 (live=텔레메트리/누적기 카운터 + 공식)', () => {
|
|
183
193
|
const { sim } = runSimRich()
|
|
184
|
-
const m = sim.
|
|
194
|
+
const m = sim.equipment.find((x: any) => x.oee && (x.oee.runMs > 0 || x.oee.downMs > 0)) ?? sim.equipment.find((x: any) => x.oee)
|
|
185
195
|
assert.ok(m?.oee, 'sim 무버 oee 존재')
|
|
186
196
|
const o = m.oee
|
|
187
197
|
|
|
@@ -197,3 +207,27 @@ test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE
|
|
|
197
207
|
assert.ok(near(derived.quality, o.quality), 'quality 재생산')
|
|
198
208
|
assert.ok(near(derived.overall, o.overall), 'overall 재생산')
|
|
199
209
|
})
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* ── 배선 감시: 라이브가 커널에 **모든 채널을 넘기는가** ──────────────────────
|
|
213
|
+
*
|
|
214
|
+
* 규칙(deriveAttentions)은 커널 테스트가 지킨다. 지켜지지 않던 것은 **배선**이다 —
|
|
215
|
+
* `withLiveAttentions` 가 채널 하나를 안 넘기면 같은 공장이 시뮬에서는 막혔다고 하고 라이브에서는
|
|
216
|
+
* 멀쩡하다고 답한다. 규칙은 멀쩡하니 규칙 테스트는 통과하고, 어긋남은 아무 데서도 안 잡힌다.
|
|
217
|
+
* (`tasks` 를 새로 넣을 때 실제로 그럴 뻔했다.)
|
|
218
|
+
*
|
|
219
|
+
* 그래서 채널마다 신호가 하나씩 나오는 상태를 만들고, **넷이 다 나오는지** 본다. 앞으로 채널이
|
|
220
|
+
* 하나 더 늘고 라이브 배선만 빠지면 여기서 깨진다.
|
|
221
|
+
*/
|
|
222
|
+
test('★ withLiveAttentions 는 채널을 하나도 떨어뜨리지 않는다(설비·자리·오더·작업)', () => {
|
|
223
|
+
const state = {
|
|
224
|
+
nowTime: '2026-03-02T12:00:00.000Z',
|
|
225
|
+
equipment: [{ id: 'rbt-1', status: 'down', location: 'wc-1' }],
|
|
226
|
+
locations: [{ id: 'wc-1', capacity: 2, occupancy: 2 }],
|
|
227
|
+
orders: [{ id: 'ord-1', held: true, status: 'released' }],
|
|
228
|
+
tasks: Array.from({ length: 8 }, (_, i) => ({ id: `t${i}`, kind: 'paint', status: 'created' }))
|
|
229
|
+
}
|
|
230
|
+
const kinds = new Set((withLiveAttentions(state as any).attentions ?? []).map((a: any) => a.kind))
|
|
231
|
+
for (const expected of ['breakdown', 'bottleneck', 'hold', 'work-backlog'])
|
|
232
|
+
assert.ok(kinds.has(expected), `라이브 배선이 '${expected}' 채널을 떨어뜨렸다 — 넘긴 것: ${[...kinds].join(', ') || '없음'}`)
|
|
233
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* masterToTwin — 모델→board 토폴로지 변환 시임. 템플릿/레퍼런스 master → board{
|
|
2
|
+
* masterToTwin — 모델→board 토폴로지 변환 시임. 템플릿/레퍼런스 master → board{locations,equipment,layout,spaceId}+space.
|
|
3
3
|
* 스트림라인의 "모델링→board" 연결부: 템플릿(build)이 낸 master 를 커널이 소비할 BoardDef 로 바꾼다.
|
|
4
|
-
* role(
|
|
4
|
+
* role(location/area/both) 분기·footprint→layout·spaceId 파생·미지 타입 경고를 고정.
|
|
5
5
|
*/
|
|
6
6
|
import { test } from 'node:test'
|
|
7
7
|
import assert from 'node:assert/strict'
|
|
@@ -17,22 +17,22 @@ const wmsMaster = () => ({
|
|
|
17
17
|
space: { width: 90, depth: 40, unit: 'm', geo: { lat: 37.5, lon: 127.0, rotation: 0 } },
|
|
18
18
|
locations: [
|
|
19
19
|
{ id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
|
|
20
|
-
{ id: 'dock-recv', type: 'dock', role: '
|
|
21
|
-
{ id: 'rack-1', type: 'storage', role: '
|
|
22
|
-
{ id: 'rack-2', type: 'storage', role: '
|
|
23
|
-
{ id: 'dock-ship', type: 'dock-ship', role: '
|
|
20
|
+
{ id: 'dock-recv', type: 'dock', role: 'location' as const, name: '입고 도크', capacity: 100 },
|
|
21
|
+
{ id: 'rack-1', type: 'storage', role: 'location' as const, name: '랙1', parentId: 'a-dc', capacity: 60 },
|
|
22
|
+
{ id: 'rack-2', type: 'storage', role: 'location' as const, name: '랙2', parentId: 'a-dc', capacity: 60 },
|
|
23
|
+
{ id: 'dock-ship', type: 'dock-ship', role: 'location' as const, name: '출고 도크', capacity: 100 }
|
|
24
24
|
],
|
|
25
25
|
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }]
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
test('locations role → 노드/영역 분기, equipment → 무버', () => {
|
|
29
29
|
const { board, spaceContent } = masterToTwin(wmsMaster())
|
|
30
|
-
assert.deepEqual(board.
|
|
30
|
+
assert.deepEqual(board.locations.map((n: any) => n.id).sort(), ['dock-recv', 'dock-ship', 'rack-1', 'rack-2'], '노드=role location')
|
|
31
31
|
assert.equal(spaceContent.areas.length, 1, '영역=role area')
|
|
32
32
|
assert.equal(spaceContent.areas[0].id, 'a-dc')
|
|
33
|
-
assert.deepEqual(board.
|
|
34
|
-
assert.equal(board.
|
|
35
|
-
assert.equal(board.
|
|
33
|
+
assert.deepEqual(board.equipment, [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv', mtbfMs: undefined, mttrMs: undefined, gs1Id: undefined }], '마스터 equipment→보드 equipment(homeLocationId→homeLocation, gs1Id 통과)')
|
|
34
|
+
assert.equal(board.locations.find((n: any) => n.id === 'rack-1').capacity, 60, 'capacity 전달')
|
|
35
|
+
assert.equal(board.locations.find((n: any) => n.id === 'dock-recv').parentId, undefined, 'parentId 없으면 undefined')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
test('coordinate-free: footprint 없으면 layout 비어있음(미배치 → A2)', () => {
|
|
@@ -57,10 +57,10 @@ test('spaceId: 명시 시 참조, 없으면 source 파생 (공동배치 시임)'
|
|
|
57
57
|
|
|
58
58
|
test('미지 노드 타입 경고 — 카탈로그 있으면 검증(버리진 않음)', () => {
|
|
59
59
|
const m = wmsMaster()
|
|
60
|
-
;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: '
|
|
60
|
+
;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: 'location', capacity: 1 })
|
|
61
61
|
const withCat = masterToTwin(m, DOMAIN_CATALOG)
|
|
62
62
|
assert.ok(withCat.warnings.some(w => w.includes('__unknown__')), '미지 타입 경고')
|
|
63
|
-
assert.ok(withCat.board.
|
|
63
|
+
assert.ok(withCat.board.locations.some((n: any) => n.id === 'weird'), '경고해도 노드는 유지(반영 우선)')
|
|
64
64
|
// 정상 타입만이면 경고 0
|
|
65
65
|
assert.equal(masterToTwin(wmsMaster(), DOMAIN_CATALOG).warnings.length, 0, '알려진 타입만 → 경고 0')
|
|
66
66
|
// 카탈로그 없으면 검증 생략
|
|
@@ -71,7 +71,7 @@ test("role 'both' 는 노드·영역 양쪽에 등장(도크 도어 등)", () =>
|
|
|
71
71
|
const m = wmsMaster()
|
|
72
72
|
;(m.locations as any).push({ id: 'dock-door-1', type: 'dock-door', role: 'both', name: '도어1', capacity: 1 })
|
|
73
73
|
const { board, spaceContent } = masterToTwin(m)
|
|
74
|
-
assert.ok(board.
|
|
74
|
+
assert.ok(board.locations.some((n: any) => n.id === 'dock-door-1'), 'both → 노드')
|
|
75
75
|
assert.ok(spaceContent.areas.some((a: any) => a.id === 'dock-door-1'), 'both → 영역')
|
|
76
76
|
})
|
|
77
77
|
|
|
@@ -111,3 +111,77 @@ test('현장 물질화(#4): 표현도 geo 도 없으면 빈 표현 + 경고(정
|
|
|
111
111
|
'뷰 없는 공간 경고 표면화'
|
|
112
112
|
)
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
/* ── 공정 명세 통과 (ISA-95 OperationsSegment) ──────────────────────────────
|
|
116
|
+
*
|
|
117
|
+
* 시뮬레이션의 소요·수율이 커널 상수였던 것을 데이터로 옮기는 경로의 마지막 한 칸: 마스터가 명세를
|
|
118
|
+
* 말하면 board 를 통해 커널까지 **손대지 않고** 간다. 기간은 표준 표기(ISO 8601)로 그대로 흐른다 —
|
|
119
|
+
* 여기서 밀리초로 바꾸면 표준과 어긋나고, 커널이 다시 해석할 기회를 잃는다.
|
|
120
|
+
*/
|
|
121
|
+
const mesMaster = () => ({
|
|
122
|
+
source: 'plant-1',
|
|
123
|
+
system: 'mes' as const,
|
|
124
|
+
siteName: '공장',
|
|
125
|
+
space: { width: 100, depth: 50, geo: { lat: 32.3, lon: -117.0 } },
|
|
126
|
+
locations: [
|
|
127
|
+
{ id: 'a-prod', type: 'factory', role: 'area' as const, name: '생산' },
|
|
128
|
+
{ id: 'cut-1', type: 'cut-station', role: 'location' as const, name: '절단', capacity: 4 }
|
|
129
|
+
],
|
|
130
|
+
equipment: [{ id: 'mch-1', kind: 'cutter', homeLocationId: 'cut-1' }],
|
|
131
|
+
operations: [
|
|
132
|
+
{
|
|
133
|
+
key: 'cut', label: 'cutting', intent: 'process' as const, locationType: 'cut-station', resourceType: 'cutter',
|
|
134
|
+
duration: 'PT25M',
|
|
135
|
+
variability: { distribution: 'triangular' as const, min: 'PT20M', max: 'PT35M', mode: 'PT24M' },
|
|
136
|
+
parameters: [{ id: 'yield', value: '0.995' }]
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('공정 명세가 board 로 그대로 통과한다 — 표준 표기 유지, 값 변형 없음', () => {
|
|
142
|
+
const { board, warnings } = masterToTwin(mesMaster(), DOMAIN_CATALOG)
|
|
143
|
+
assert.equal((board as any).operations.length, 1)
|
|
144
|
+
const op = (board as any).operations[0]
|
|
145
|
+
assert.equal(op.duration, 'PT25M', 'ISO 8601 기간을 밀리초로 바꾸지 않는다')
|
|
146
|
+
assert.deepEqual(op.variability, { distribution: 'triangular', min: 'PT20M', max: 'PT35M', mode: 'PT24M' })
|
|
147
|
+
assert.deepEqual(op.parameters, [{ id: 'yield', value: '0.995' }])
|
|
148
|
+
assert.equal(warnings.length, 0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('명세를 주지 않으면 키를 만들지 않는다 — 빈 배열로 "선언했다" 고 말하지 않는다', () => {
|
|
152
|
+
const { board } = masterToTwin(wmsMaster(), DOMAIN_CATALOG)
|
|
153
|
+
assert.equal('operations' in (board as any), false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('내용 없는 명세는 경고한다 — 선언했는데 아무 말도 하지 않는 명세는 침묵하지 않는다', () => {
|
|
157
|
+
const m: any = mesMaster()
|
|
158
|
+
m.operations = [{ key: 'cut', label: 'cutting', intent: 'process' }]
|
|
159
|
+
const { warnings } = masterToTwin(m, DOMAIN_CATALOG)
|
|
160
|
+
assert.ok(
|
|
161
|
+
warnings.some(w => w.includes('cut') && w.includes('kernel defaults')),
|
|
162
|
+
`기본값으로 굴러갈 것을 알린다: ${JSON.stringify(warnings)}`
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/* ── 처리 능력·교대 통과 ─────────────────────────────────────────────────
|
|
167
|
+
*
|
|
168
|
+
* `capacity` 한 필드가 저장 용량과 처리 능력을 겸하던 것을 갈랐다. 인제스트도 두 축을 따로 실어야
|
|
169
|
+
* 커널이 "20개를 쌓아 두지만 한 번에 한 대만 깎는다" 를 표현할 수 있다. 교대도 같다.
|
|
170
|
+
*/
|
|
171
|
+
test('처리 능력과 교대가 board 로 통과한다 — 저장 용량과 섞이지 않는다', () => {
|
|
172
|
+
const m: any = mesMaster()
|
|
173
|
+
m.locations[1].parallelism = 1 // cut-1: 저장 4, 동시 처리 1
|
|
174
|
+
m.equipment[0].window = { startHour: 8, endHour: 18 }
|
|
175
|
+
const { board } = masterToTwin(m, DOMAIN_CATALOG)
|
|
176
|
+
const location = (board as any).locations.find((n: any) => n.id === 'cut-1')
|
|
177
|
+
assert.equal(location.capacity, 4, '저장 용량')
|
|
178
|
+
assert.equal(location.parallelism, 1, '처리 능력 — 다른 축')
|
|
179
|
+
assert.deepEqual((board as any).equipment[0].window, { startHour: 8, endHour: 18 })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('선언하지 않으면 키를 만들지 않는다 — 미지정과 0 을 뭉개지 않는다', () => {
|
|
183
|
+
const { board } = masterToTwin(mesMaster(), DOMAIN_CATALOG)
|
|
184
|
+
const location = (board as any).locations.find((n: any) => n.id === 'cut-1')
|
|
185
|
+
assert.equal('parallelism' in location, false)
|
|
186
|
+
assert.equal('window' in (board as any).equipment[0], false)
|
|
187
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 모델 근거 비교 — **배운 모델에만 보정을 적용하기 위한 관문.**
|
|
3
|
+
*
|
|
4
|
+
* 배경: 예측 출력 보정(P3.2)은 "그때 모델이 이만큼 치우쳐 있다" 를 배운다. 소요시간 추정기(실측·거리)
|
|
5
|
+
* 와 공정 명세가 들어오면서 모델 자체가 바뀔 수 있게 됐고, 그러면 옛 편향을 그대로 밀어 주는 **이중
|
|
6
|
+
* 보정**이 생긴다. 근거가 달라졌으면 적용하지 않는다(그리고 그 사실을 응답에 싣는다).
|
|
7
|
+
*
|
|
8
|
+
* 비교를 **상대 허용오차**로 하는 이유: 양자화 지문은 버킷 경계에 걸린 값이 1초 드리프트로도 달라져
|
|
9
|
+
* "작은 드리프트로 학습을 무효화하지 않는다" 는 목적을 경계에서 못 지킨다. 비율은 어디서든 같다.
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
|
|
14
|
+
import { BASIS_TOLERANCE, describeModelBasis, modelBasisChanged } from '../server/engine/model-basis.ts'
|
|
15
|
+
|
|
16
|
+
test('같은 근거면 바뀌지 않았다 — 키 순서에 흔들리지 않는다', () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
modelBasisChanged({ measured: { pick: 22_000, putaway: 47_000 } }, { measured: { putaway: 47_000, pick: 22_000 } }),
|
|
19
|
+
false
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('작은 드리프트는 같은 모델 — 경계에서도 성립한다(버킷이 아니라 비율)', () => {
|
|
24
|
+
/* 47.0 → 47.9 (약 1.9%): 5초 버킷이면 45↔50 으로 갈렸을 값이다. */
|
|
25
|
+
assert.equal(modelBasisChanged({ measured: { putaway: 47_000 } }, { measured: { putaway: 47_900 } }), false)
|
|
26
|
+
/* 허용오차 경계 바로 아래·위 */
|
|
27
|
+
assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 105_000 } }), false, '5%')
|
|
28
|
+
assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 130_000 } }), true, '30%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('실측이 실제로 달라지면 다른 모델 — 30초 작업이 47초가 되면 옛 보정은 무효', () => {
|
|
32
|
+
assert.equal(modelBasisChanged({ measured: { putaway: 30_000 } }, { measured: { putaway: 47_000 } }), true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('새로 배운 종류·사라진 종류는 다른 모델 — 값이 없다가 생긴 것도 변화다', () => {
|
|
36
|
+
assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, { measured: { pick: 20_000, putaway: 47_000 } }), true)
|
|
37
|
+
assert.equal(modelBasisChanged({ measured: { pick: 20_000, putaway: 47_000 } }, { measured: { pick: 20_000 } }), true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('속도가 바뀌면 다른 모델 — 이동시간 모델이 바뀐 것이다', () => {
|
|
41
|
+
assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 4 } }), true)
|
|
42
|
+
assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 2.05 } }), false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('선언 명세가 바뀌면 다른 모델 — 표기 원문을 그대로 비교한다', () => {
|
|
46
|
+
assert.equal(
|
|
47
|
+
modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT30M' }] }),
|
|
48
|
+
true
|
|
49
|
+
)
|
|
50
|
+
assert.equal(
|
|
51
|
+
modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT25M' }] }),
|
|
52
|
+
false
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('소요 없는 선언은 모델을 바꾸지 않는다', () => {
|
|
57
|
+
assert.equal(modelBasisChanged({ declared: [{ key: 'cut' }] }, { declared: [] }), false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('뜻 없는 값은 근거가 아니다 — 0·음수·NaN 은 무시한다', () => {
|
|
61
|
+
assert.equal(modelBasisChanged({ measured: { pick: 0 } }, {}), false)
|
|
62
|
+
assert.equal(modelBasisChanged({ measured: { pick: NaN } }, { speeds: { f: -1 } }), false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('한쪽 근거를 모르면 판정하지 않는다 — 모르는 것을 근거로 학습을 끊지 않는다', () => {
|
|
66
|
+
assert.equal(modelBasisChanged(undefined, { measured: { pick: 20_000 } }), false)
|
|
67
|
+
assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, undefined), false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('세 재료가 섞이지 않는다 — 같은 이름이 다른 재료에 있으면 다른 모델', () => {
|
|
71
|
+
assert.equal(modelBasisChanged({ measured: { x: 10_000 } }, { speeds: { x: 10_000 } }), true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('허용오차는 조절 가능하고 기본값이 명시돼 있다', () => {
|
|
75
|
+
assert.equal(BASIS_TOLERANCE, 0.1)
|
|
76
|
+
assert.equal(modelBasisChanged({ measured: { p: 100 } }, { measured: { p: 130 } }, 0.5), false, '느슨하게 주면 같은 모델')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('사람이 읽는 요약을 낸다 — 로그에서 왜 달라졌는지 눈으로 본다', () => {
|
|
80
|
+
const d = describeModelBasis({ measured: { putaway: 47_000 }, speeds: { forklift: 2 }, declared: [{ key: 'cut', duration: 'PT25M' }] })
|
|
81
|
+
assert.match(d, /measured\(putaway=47s\)/)
|
|
82
|
+
assert.match(d, /speed\(forklift=2m\/s\)/)
|
|
83
|
+
assert.match(d, /declared\(cut=PT25M\)/)
|
|
84
|
+
assert.equal(describeModelBasis({}), 'kernel defaults only')
|
|
85
|
+
assert.equal(describeModelBasis(undefined), 'unknown')
|
|
86
|
+
})
|