@operato/twin-kernel 0.7.56 → 0.7.58
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 +134 -45
- package/dist/contract.d.ts +100 -1
- package/dist/contract.js +58 -0
- package/dist/face2-adapter.d.ts +16 -0
- package/dist/face2-adapter.js +22 -0
- package/dist/observed-reducer.d.ts +5 -1
- package/dist/observed-reducer.js +43 -1
- package/dist-cjs/index.cjs +60 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
|
-
# @operato-
|
|
1
|
+
# @operato/twin-kernel — 비즈니스레이어 디지털트윈 커널
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
물류창고·야드·스마트팩토리·에너지의 **비즈니스 실행**을 시뮬레이션·모니터링하는 **헤드리스·프레임워크 무관·zero-dep** TS 커널. UI/3D/DOM 없이 순수 Node 에서 동작하는 것이 목적(sim=능동 생산, live=수동 미러, **계약 동일**).
|
|
4
4
|
|
|
5
|
-
- 실행: `node --test test/*.test.ts`
|
|
6
|
-
- 상태: **v0.
|
|
5
|
+
- 실행: `node --test test/*.test.ts` — 네이티브 TS, **런타임 의존성 0개**
|
|
6
|
+
- 상태: **v0.7.56 · 1,085 tests green** · 시험 파일 116개 · 4 버티컬(WMS/YMS/MES/EMS) · ISA-95 4대 자원
|
|
7
7
|
|
|
8
8
|
## 계층 (아래로만 의존, 무방언)
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
contract.ts
|
|
12
|
-
epcis.ts
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
observed-reducer.ts
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
contract.ts 3채널 계약(State/Command/Scenario) + 운영 델타 + TwinKernel ← 도메인/표준 무관
|
|
12
|
+
epcis.ts GS1 EPCIS 2.0 이벤트 machinery(타입·빌더·검증기·URI·CBV) ← 표준(도메인 무관)
|
|
13
|
+
vocabulary.ts 은퇴 어휘 목록 + 가드 프라그마 — 개명을 기계가 지킨다
|
|
14
|
+
flow-engine.ts FlowEngine base — mechanics(RNG·clock·tick·자원배정·태스크진행·emit·snapshot)
|
|
15
|
+
observed-reducer.ts 관측 이벤트 → State 접기(**두 구동이 쓰는 유일한 규칙**) + 재개점 ← 도메인 무관
|
|
16
|
+
state-projector.ts 위 것의 옛 이름(소비처 호환 별칭)
|
|
17
|
+
|
|
18
|
+
allocation-policy.ts AllocationPolicy 확장 시임(selectPlacement/selectStock)
|
|
19
|
+
capability.ts 자원의 능력 선언 — concrete 결합 대신 능력으로 찾는다
|
|
20
|
+
capacity.ts 용량·점유 계산
|
|
21
|
+
operations-capability.ts 공정이 요구하는 능력 ↔ 자원이 가진 능력의 대조
|
|
22
|
+
domain-definition.ts 공정 명세(ISA-95 OperationsSegment: 소요·변동·수율·자원 요구)
|
|
23
|
+
domain-catalog.ts 도메인 어휘 카탈로그(선언 가능한 것의 목록)
|
|
24
|
+
duration-estimator.ts 실측 추정기 주입 시임
|
|
25
|
+
iso-duration.ts xsd:duration 파서(달력 의존 단위는 거절)
|
|
26
|
+
make-to-order.ts 주문 대응 생산
|
|
27
|
+
task-fold.ts 태스크 접기
|
|
28
|
+
job-response.ts ISA-95 작업 응답
|
|
29
|
+
|
|
30
|
+
event-journal.ts 이벤트 저널 + replay/replayFrom — 트윈의 기억
|
|
31
|
+
forecast.ts 몬테카를로 예측(분포로 답한다)
|
|
32
|
+
divergence.ts 예측 vs 관측 대조 → 드리프트 국소화
|
|
33
|
+
counterfactual.ts 과거 시점 분기 — "그때 X 했다면"
|
|
34
|
+
twin-observer.ts 자동 정합 루프
|
|
35
|
+
|
|
36
|
+
energy-ingest.ts 계측 유입(적산·구간)
|
|
37
|
+
energy-attribution.ts 에너지를 사용처로 귀속
|
|
38
|
+
operational-ingest.ts 운영 사실 유입
|
|
39
|
+
scenario-validate.ts 시나리오 선언 검증
|
|
40
|
+
face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증) ← ACL
|
|
41
|
+
runtime.ts TwinRuntime — host-facing facade + 구독 프로토콜 ← 도메인/전송 무관
|
|
42
|
+
|
|
43
|
+
{wms,yms,mes,ems}-profile.ts 도메인 어휘(bizStep/btt/표준 앵커)만
|
|
44
|
+
kernel.ts · {yms,mes,ems}-kernel.ts 도메인 flow 동사(4 hook) — FlowEngine 확장
|
|
23
45
|
```
|
|
24
46
|
|
|
25
|
-
> **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). order.kind
|
|
47
|
+
> **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). `order.kind`·`task.resourceType` 등은 도메인이 소유. 정책 인터페이스는 `selectPlacement`/`selectStock`(≠putaway/pallets).
|
|
26
48
|
|
|
27
49
|
## FlowEngine — 단일 base, 도메인은 4 hook 만
|
|
28
50
|
|
|
@@ -35,17 +57,18 @@ face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증)
|
|
|
35
57
|
| `allocate(order)` | created 오더 할당 → 재고 선택(정책) + 태스크. **시간창 게이트도 여기**(스케줄링) |
|
|
36
58
|
| `onTaskComplete(task)` | 완료의 의미 — 이동(WMS/YMS) 또는 변환(MES). occupancy·EPCIS·오더 이행 전부 도메인 |
|
|
37
59
|
|
|
38
|
-
base 는
|
|
60
|
+
base 는 태스크 **생명주기**(자원 배정·진행·완료·해제·델타)만 소유 → 이동-중립. 새 버티컬 = 프로파일(어휘) + kernel(4 hook).
|
|
39
61
|
|
|
40
|
-
##
|
|
62
|
+
## 4 버티컬 — base 가 여러 flow 성격을 담음을 실증
|
|
41
63
|
|
|
42
64
|
| 버티컬 | flow 성격 | base 대응 |
|
|
43
65
|
|---|---|---|
|
|
44
66
|
| **WMS** | 이동 (putaway/pick/pack/ship) + 백오더 | 기본 |
|
|
45
67
|
| **YMS** | 이동 (spot/pull) + **시간창 스케줄링**(어포인트먼트↔도크도어 예약) | 스케줄링=도메인 게이팅으로 **base 흡수** |
|
|
46
|
-
| **MES** | **변환**(TransformationEvent) + 다단계 라우팅 + **이종 자원**(cutter/welder) | 자원 매칭=`FlowTask.resourceType` **base 확장**
|
|
68
|
+
| **MES** | **변환**(TransformationEvent) + 다단계 라우팅 + **이종 자원**(cutter/welder) | 자원 매칭=`FlowTask.resourceType` **base 확장** |
|
|
69
|
+
| **EMS** | 계측의 흐름 — 적산·구간 마감·피크·귀속 | 유입·귀속 모듈로 확장(자리=**전기적 구간**) |
|
|
47
70
|
|
|
48
|
-
|
|
71
|
+
EMS 는 **표준 앵커가 다르다**: 물류·생산은 ISA-95/EPCIS 로 재고 에너지는 **IEC 61850**(설비 데이터 모델)·**ISO 50001**(에너지 경영)로 잰다. 대응할 표준 이름이 없으면 그 칸을 **비운다** — 억지로 가까운 이름을 적으면 적합성 표가 거짓을 말한다.
|
|
49
72
|
|
|
50
73
|
## 4대 자원 (ISA-95)
|
|
51
74
|
|
|
@@ -54,7 +77,7 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
54
77
|
| ISA-95 | 커널 | 요구 명세 |
|
|
55
78
|
|---|---|---|
|
|
56
79
|
| Personnel | `persons` · `PersonState` | `personnelSpecification` = 등급 + 인원 수 |
|
|
57
|
-
| Equipment | `
|
|
80
|
+
| Equipment | `equipment` · `EquipmentState` | `equipmentSpecification` = 등급 + 대수 |
|
|
58
81
|
| PhysicalAsset | `assets` · `AssetState` (GS1 **GRAI**) | `physicalAssetSpecification` |
|
|
59
82
|
| Material | `items` · `ItemState` (EPCIS) | — |
|
|
60
83
|
|
|
@@ -62,6 +85,24 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
62
85
|
|
|
63
86
|
> 팔레트를 물품으로 두지 않은 이유: GS1 에서 **SSCC**(물류단위)와 **GRAI**(돌아오는 팔레트 자체)는 다른 것이다. 같은 GRAI 가 오늘은 이 SSCC 를, 내일은 다른 SSCC 를 싣는다. 그래서 **신설하고 연결**한다(`AssetState.carrying` ↔ `ItemState.carriedBy`).
|
|
64
87
|
|
|
88
|
+
자원에는 **자격**이 붙는다. 등급이 시험을 요구하고(`testSpecificationIds`) 개체가 결과를 든다(`testResults`) — 요구된 시험의 결과가 없거나 만료·불합격이면 자격이 성립하지 않는다(`meetsTests`). 결과가 **아예 없는 것**과 **불합격**을 같은 값으로 만들지 않는다.
|
|
89
|
+
|
|
90
|
+
## 자리와 그 물리 조건
|
|
91
|
+
|
|
92
|
+
`locations` · `LocationState` — 자리는 ISA-95 설비 계층의 노드다(Enterprise→Site→Area→StorageZone→StorageUnit).
|
|
93
|
+
|
|
94
|
+
자리는 **물리 조건**을 든다: `observations`(속성별 최신 관측 — 온도·습도 등)와 `testSpecificationIds`(그 자리에 걸린 기준). 조건 없이는 물건의 상태를 판정할 수 없고, **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건 이력을 모른다.
|
|
95
|
+
|
|
96
|
+
| 축 | 표준 | 우리가 더한 것 |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| 관측 | `OperationsEvent` + `OperationsRecordTemplate`(`EffectiveTimestamp`·`EffectiveEndDate`·`HierarchyScope`) | 값을 `ValueType` 으로 **좁혔다**(단위 없는 물리량은 판정의 재료가 못 된다) |
|
|
99
|
+
| 기준 | `TestSpecificationCriteria`(`Expression` = 자유 문장) | **숫자 한계** `limit: {minimum, maximum, uom}` — 자유 문장으로는 판정할 수 없다 |
|
|
100
|
+
| 판정 | `TestResult.EvaluatedCriterionResult`(TextType) | `pass`/`fail` 로 좁힘 · CBV 처분 `conformant`/`non_conformant` |
|
|
101
|
+
|
|
102
|
+
판정 함수 `outsideLimit()` 은 **세 갈래로 답한다** — 벗어남(`true`) · 안(`false`) · **판정 못 함(`undefined`)**. 숫자 한계가 없거나, 값이 수가 아니거나, 양쪽이 서로 다른 단위를 말하면 거절한다. 「모름」을 「적합」으로 만들지 않기 위한 것이고, 규제 기록에서 그 구별이 사라지면 결함이 아니라 사고다. 근거의 빈 곳은 `testEvidenceGaps()` 가 따로 센다.
|
|
103
|
+
|
|
104
|
+
관측은 **사건으로 들어온다**(`OP_EVENT.observation` = `location.measured`) — 상태에만 있는 축은 조용히 사라지는 축이다. 구간 관측(`effectiveEndTime`)은 그 구간 안에서만 참이다: 원본이 「그날 아침」만 말하면 09:00 을 지어내지 않는다.
|
|
105
|
+
|
|
65
106
|
## 공정 명세와 소요시간 세 층
|
|
66
107
|
|
|
67
108
|
`OperationDef` 가 ISA-95 `OperationsSegment` 를 담는다 — `duration`(xsd:duration) · `variability`(퍼짐) · `parameters`(수율·준비시간) · 자원 요구 셋. 소요시간은 **강한 근거부터** 찾는다:
|
|
@@ -74,48 +115,96 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
74
115
|
|
|
75
116
|
## 3채널 계약 (Face 1) + 구독
|
|
76
117
|
|
|
77
|
-
- **State**: `getSnapshot()`
|
|
78
|
-
- **Command**
|
|
79
|
-
- **Scenario**: `scenario.load/start/pause/setSpeed`(시뮬 자극).
|
|
118
|
+
- **State**: `getSnapshot()` + 델타 이벤트 스트림. 델타 = EPCIS(재고/위치/조립/변환) + 운영 델타(`task/equipment/order.status`, EPCIS 로 재구성 불가한 절반) + 관측(`location.measured`).
|
|
119
|
+
- **Command**(행위/act): `dispatch(cmd)` 가 실제로 상태를 변이 → State 델타 유발(폐루프). 공통 `order.hold`/`resume` + 도메인 확장은 `handleCommand` 시임.
|
|
120
|
+
- **Scenario**: `scenario.load/start/pause/setSpeed`(시뮬 자극). 선언은 `scenario-validate.ts` 가 검증한다.
|
|
80
121
|
- **TwinRuntime**: `subscribe`(snapshot→delta, revision 연속) · `resync` · `tick`. sparse 스트리밍(전이·move-start 만, 매 tick 아님).
|
|
81
122
|
|
|
123
|
+
`StateSnapshot` 의 축: `locations` · `items` · `equipment` · `persons` · `assets` · `tasks` · `orders` · `attentions` · `energy` · `conformance` · `nowTime` · `identityGrounding` · `unhandled` · `stepsWithoutMaterial`.
|
|
124
|
+
|
|
125
|
+
**`nowTime` 은 트윈이 직접 말한다.** 소비처가 벽시계로 대신 재면 시뮬 트윈에서 엉뚱한 값이 나오고, 관측 모드에서는 「지금」이 마지막으로 들은 발생 시각이다. 없으면 소비처는 **재지 않는다**.
|
|
126
|
+
|
|
82
127
|
## State 이원 모델
|
|
128
|
+
|
|
83
129
|
- **EPCIS 저널**(이산): 재고·위치·조립·변환. `epcis.ts` 로 정규 방출, `validateEpcisEvent` 검증.
|
|
84
|
-
- **운영·키네마틱**(연속):
|
|
85
|
-
- **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑).
|
|
130
|
+
- **운영·키네마틱**(연속): 설비 motion(from/to/progress)·태스크·오더 진척. 운영 델타로 미러.
|
|
131
|
+
- **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑).
|
|
86
132
|
|
|
87
133
|
### 구동은 둘, 규칙은 하나 — 적합성 하네스
|
|
88
134
|
|
|
89
|
-
상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히
|
|
135
|
+
상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히 어긋난다** — 계약 필드가 거의 다 선택이라 "안 채우는 것이 합법"이고, 컴파일러가 잡아 주지 않는다.
|
|
136
|
+
|
|
137
|
+
> **불변식: 커널 상태의 모든 사실은 이벤트로 나가야 한다 (상태 ⊆ 이벤트).**
|
|
90
138
|
|
|
91
|
-
|
|
139
|
+
나가지 않는 사실은 미러가 모르고, 저널로 복원되지도 않고(시간여행), 미러에서 세운 예측 씨앗에도 실리지 않는다. `test/driver-conformance.test.ts` 가 시뮬을 굴려 **그 이벤트를 그대로 미러에 흘리고** 두 스냅샷을 대조한다. 정당한 예외는 주석이 아니라 **상수로** 들고 이유를 적는다(호스트가 적분하는 `oee`, 매 tick 가지 않는 보간값 — 대신 **앵커는 반드시 같다**).
|
|
92
140
|
|
|
93
|
-
|
|
141
|
+
## 재기동 — 재개점에서 이어 접는다
|
|
142
|
+
|
|
143
|
+
저널을 처음부터 다시 접지 않기 위한 축이다. 실 저널이 수천만 줄인 현장에서 이것은 성능이 아니라 **가능/불가능**의 문제다.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
observedCheckpoint(): ReducerCheckpoint | undefined // 재개점을 꺼낸다(관측 구동이 아니면 undefined)
|
|
147
|
+
restoreObserved(cp: ReducerCheckpoint): void // 재개점에서 되세운다
|
|
148
|
+
replayFrom(model, checkpoint, events) // 재개점 + 꼬리만 접는다
|
|
149
|
+
```
|
|
94
150
|
|
|
95
|
-
|
|
96
|
-
관측·예측·행위가 따로 있는 건 mirror+simulator. 트윈의 본질은 그 **커플링**:
|
|
97
|
-
- **`kernel.fork()`** — 현재 상태(in-flight 포함)를 정확히 복제한 격리 엔진. 원본(live/sim)은 계속 가고, fork 는 **현재로부터 앞으로 굴려** forecast(완료 시각·처리량)·대안 what-if 탐색.
|
|
98
|
-
- **`compareStates(predicted, actual)`** — fork 예측 vs 실제 관측을 같은 시점에 대조 → **드리프트(모델↔현실 이탈) 탐지·국소화**(item/node/order 필드별). 발산 = 이상/개입 신호.
|
|
99
|
-
- 루프: 관측(현재) → fork 예측(미래) → 관측(실제) → 정합(발산) → 행위(개입).
|
|
100
|
-
- **`EventJournal` + `replay(board, events)`** — 트윈의 **기억**: 이벤트열이 곧 상태(이벤트-소싱). `journal.until(revision)`/`untilSimTime(iso)` 을 projector 로 재생 → **임의 과거 시점 상태 재구성(시간여행)**. 감사·리플레이·발산 비교의 기반. things-factory 에선 append 를 DB 이벤트 테이블로 교체.
|
|
151
|
+
> **상태 스냅샷은 씨앗이 되지 못한다.** 리듀서는 소비처가 보는 값 말고도 든다 — 부모를 기다리는 담김·집계 중인 수량·담을 자리를 몰라 세어 둔 사건. 상태만 되돌리고 뒤를 이어 접으면 **0부터 접은 결과와 조용히 달라진다.**
|
|
101
152
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
153
|
+
그 동치(`재개점 + 꼬리 == 0부터 접기`)는 `test/observed-checkpoint.test.ts` 가 증명한다. 배경·시행착오·호스트 배선은 [`design/fold-and-resume.md`](../../design/fold-and-resume.md).
|
|
154
|
+
|
|
155
|
+
원본이 **되풀어 주지 않는 축**은 따로 이어받는다(`hydrateContinuity`) — 열린 수요 구간의 누적·적산 기준점·관측 이후 최대·신호가 언제부터인지. 관측 축(재고·자리·설비)은 그 문으로 심지 않는다.
|
|
156
|
+
|
|
157
|
+
## 주의 신호 — 커널이 판정하고, 문장은 만들지 않는다
|
|
158
|
+
|
|
159
|
+
`attentions` 는 커널이 스스로 내는 판정이다(`deriveAttentions`/`collectAttentions`). 각 신호는 `kind` · `severity` · `anchor`(어디의 일인가) · `params`(근거가 된 값)를 든다.
|
|
160
|
+
|
|
161
|
+
**문장을 만들지 않는다.** 커널은 키와 값만 내고 표현은 소비처가 한다 — 커널이 한국어 문장을 들면 그 트윈은 한 언어에 묶인다. 그리고 판정에는 **근거를 함께 싣는다**: 한계를 넘은 관측 신호는 값·단위·한계·**언제의 값인지**·잰 것인지 접은 것인지를 모두 낸다. 센서가 멈춘 현장에서 그 시각이 없으면 사람은 방금 벗어난 것으로 읽는다.
|
|
162
|
+
|
|
163
|
+
판정할 수 없으면 **아무 말도 하지 않는다** — 없는 이탈을 만들지도, 확인되지 않은 합격을 만들지도 않는다.
|
|
164
|
+
|
|
165
|
+
## 트윈 본연 — 현재로부터 예측 + 정합
|
|
166
|
+
|
|
167
|
+
관측·예측·행위가 따로 있는 것은 mirror+simulator 다. 트윈의 본질은 그 **커플링**:
|
|
168
|
+
|
|
169
|
+
- **`fork()`** — 현재 상태(in-flight 포함)를 정확히 복제한 격리 엔진. 원본(live/sim)은 계속 진행하고, fork 는 **현재로부터 앞으로** 굴려 완료 시각·처리량을 예측하고 what-if 를 탐색한다. 상태 + 시나리오 + **RNG state** 까지 복제하는 진짜 continuation.
|
|
170
|
+
- **`compareStates(predicted, actual)`** — 같은 시점에 대조해 드리프트(모델↔현실 이탈)를 국소화한다(item/location/order 필드별). 발산 = 이상·개입 신호.
|
|
171
|
+
- **`monteCarloForecast`** — seed 변주 N개 fork → 지표를 **분포**로(min/mean/p50/p90/max). 「언제 끝나나」가 아니라 「P90 완료시각·소진 확률」.
|
|
172
|
+
- **`EventJournal` + `replay`/`replayFrom`** — 트윈의 기억. `until(revision)`/`untilSimTime(iso)` 로 **임의 과거 시점 재구성**(시간여행). 호스트에서는 append 를 DB 이벤트 표로 교체한다.
|
|
173
|
+
- **`TwinHistory` + `counterfactualAt`** — 과거 시점 T 로 돌아가 **대안 결정을 fork** → baseline 과 대조. 「그때 X 했다면」의 효과.
|
|
174
|
+
- **`TwinObserver`** — 주기적으로 fork-예측 후 실제가 그 시점에 도달하면 대조 → 드리프트를 알림 이벤트로.
|
|
175
|
+
|
|
176
|
+
예측은 **자기 한계를 함께 말한다**: 계보를 잇지 못한 구간(`stepsWithoutMaterial`)과 원본이 개체를 말하지 않아 막힌 오더(`blocked-source-omits-material`)를 결과에 싣는다. 조용히 넘기면 계보가 빈 예측이 정상처럼 보인다.
|
|
107
177
|
|
|
108
178
|
## 확장 지점
|
|
179
|
+
|
|
109
180
|
- **버티컬 추가**: `{x}-profile.ts`(어휘) + `{x}-kernel.ts`(4 hook).
|
|
181
|
+
- **능력(capability)으로 찾기**: concrete 타입에 강결합하지 않는다 — 자원이 능력을 선언하고 공정이 능력을 요구한다.
|
|
110
182
|
- **할당 정책**: `AllocationPolicy` 교체(firstFit/partialFit 내장, FEFO/nearest/zone 추가 가능).
|
|
183
|
+
- **소요시간 추정기**: 실측 기반 추정기를 호스트가 주입.
|
|
111
184
|
- **Face2 어댑터**: 실 시스템 페이로드 → 선언적 매핑 → 정규 EPCIS.
|
|
112
185
|
- **host 결합**: `TwinRuntime` 를 GraphQL sub·서비스로 래핑(전송은 얇은 host 계층).
|
|
113
186
|
|
|
114
187
|
## 미구현(범위 밖)
|
|
115
|
-
host 결합(things-factory 전송/퍼시스턴스/커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
|
|
116
188
|
|
|
117
|
-
|
|
189
|
+
host 결합(전송·퍼시스턴스·커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
|
|
190
|
+
|
|
191
|
+
**모델의 남은 공백**(정직하게):
|
|
192
|
+
|
|
193
|
+
- 인원 **숙련도** — 등급과 자격 시험은 있으나 숙련도별 소요 차이는 없다
|
|
194
|
+
- 자산 **회수 작업** — 빈 팔레트가 도착 자리에 남을 뿐
|
|
195
|
+
- `equipment`/`locations` **계층 겸직** — 한 목록이 이동설비와 워크센터를, 다른 목록이 주소 가능한 자리와 작업 자리를 함께 든다(개명으로 이름은 정리됐고 계층은 그대로다)
|
|
196
|
+
- **next-event 도약** — 지금은 고정 간격 tick
|
|
197
|
+
- 라이브 **예측** — 미러 상태에서 fork 는 되지만 라이브 자체의 예측 경로는 통합 보류
|
|
198
|
+
- 구조가 **여러 번 갈린** 저널의 마디별 재개점 — 지금은 최신 하나
|
|
199
|
+
|
|
200
|
+
**1.0 게이트**: `equipment`/`locations` 계층을 정리하거나 가산적으로만 가능하도록 확정한 뒤. 계약 파괴가 예정된 채로 1.0 을 주면 다음 라운드가 곧 2.0 이 된다.
|
|
201
|
+
|
|
202
|
+
## 설계 SoT
|
|
118
203
|
|
|
119
|
-
|
|
204
|
+
`operato-twin/design/` —
|
|
120
205
|
|
|
121
|
-
|
|
206
|
+
- [`fold-and-resume.md`](../../design/fold-and-resume.md) — 저널 접기·재개점(시행착오·원칙·현 구현)
|
|
207
|
+
- [`runtime-state-model.md`](../../design/runtime-state-model.md) — 라이브=권위 / 히스토리=파생, 시각축, 정체성
|
|
208
|
+
- [`04-decisions.md`](../../design/04-decisions.md) — ADR
|
|
209
|
+
- `plans/simulation-spec.md`(명세·추정기·예측 자격) · `plans/four-resources-and-conformance.md`(4대 자원·불변식) · `plans/kernel-unification-live-observe.md`(구동 통합)
|
|
210
|
+
- `profiles/ems.md` — 에너지 프로파일의 표준 앵커
|
package/dist/contract.d.ts
CHANGED
|
@@ -315,8 +315,25 @@ export interface TestResult {
|
|
|
315
315
|
/**
|
|
316
316
|
* 합격 여부 — **우리가 좁힌 것이다.** 표준 `EvaluatedCriterionResult` 는 `TextType` 이고
|
|
317
317
|
* 판정 어휘를 열거하지 않는다(원문 대조 2026-08-24). 넓힐 때는 이 주석과 함께 움직인다.
|
|
318
|
+
*
|
|
319
|
+
* ── 왜 선택인가 (2026-08-24) ──────────────────────────────────────────────
|
|
320
|
+
* **재기만 하고 판정하지 않는 원천이 정상이다.** 계측기는 값을 내고 판정은 규정이 한다. 그런데 이
|
|
321
|
+
* 필드가 필수이면 그런 원천의 사실을 **아예 담을 수 없다** — 판정을 지어내거나 측정값을 버리거나
|
|
322
|
+
* 둘 중 하나가 되고, 둘 다 이 저장소가 거절하는 것이다(결측을 값으로 바꾸지 않는다).
|
|
323
|
+
*
|
|
324
|
+
* 비어 있는 것은 **「모른다」이며 「합격」이 아니다.** `testPassedAt` 이 그것을 지킨다(거짓을 낸다).
|
|
325
|
+
* 기준이 선언돼 있으면 커널이 채울 수 있다(`judgeAgainstSpec`) — 그때는 `derived` 가 선다.
|
|
318
326
|
*/
|
|
319
|
-
result
|
|
327
|
+
result?: 'pass' | 'fail';
|
|
328
|
+
/**
|
|
329
|
+
* 이 판정을 **우리가 냈나** — 원천이 판정하지 않아 선언된 기준으로 커널이 채운 것.
|
|
330
|
+
*
|
|
331
|
+
* 관측의 `derived` 와 같은 낱말·같은 뜻이다(낱말을 둘로 만들지 않는다). 규제 기록에서 「현장이
|
|
332
|
+
* 판정했다」와 「트윈이 계산했다」가 구별되지 않으면, 그 기록은 사고 뒤에 쓸 수 없다.
|
|
333
|
+
*
|
|
334
|
+
* 원천의 판정에는 표식이 없다 — **없음이 「원천이 말했다」다.**
|
|
335
|
+
*/
|
|
336
|
+
derived?: boolean;
|
|
320
337
|
/** 언제 통과·불합격했나(ISO) — 표준 `EvaluationDate`. */
|
|
321
338
|
at?: ISOTime;
|
|
322
339
|
/**
|
|
@@ -339,6 +356,16 @@ export interface TestResult {
|
|
|
339
356
|
*/
|
|
340
357
|
propertyMeasurements?: PropertyMeasurement[];
|
|
341
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* `OP_EVENT.test` 의 페이로드 — **결과가 대상을 가리킨다.**
|
|
361
|
+
*
|
|
362
|
+
* 표준(`TestResultType`)의 방향 그대로다. 상태에서는 개체 안에 접어 두지만(소비처가 대상별로 묻는다)
|
|
363
|
+
* 사건에서는 가리킨다 — 그래야 대상이 무엇이든(로트·설비·사람·자리) 한 채널로 들어온다.
|
|
364
|
+
*/
|
|
365
|
+
export interface TestResultFact extends TestResult {
|
|
366
|
+
/** 무엇을 시험했나 — 표준 `TestResult.TestableObjectID`. 물품이면 EPC. */
|
|
367
|
+
testableObjectId: string;
|
|
368
|
+
}
|
|
342
369
|
/**
|
|
343
370
|
* 이 시험 결과가 **이 시각에 유효한 합격인가.**
|
|
344
371
|
*
|
|
@@ -547,6 +574,32 @@ export declare function testEvidenceGaps(spec: Pick<TestSpecification, 'criteria
|
|
|
547
574
|
unmeasured: string[];
|
|
548
575
|
unmatched: string[];
|
|
549
576
|
};
|
|
577
|
+
/**
|
|
578
|
+
* **선언된 기준으로 판정한다** — 원천이 판정하지 않았을 때 커널이 답을 낼 수 있는가.
|
|
579
|
+
*
|
|
580
|
+
* ── 왜 이 함수가 있나 (당위) ────────────────────────────────────────────────
|
|
581
|
+
* 트윈은 **아무도 판정하지 않을 때 판정해야 한다.** 값이 있고 기준이 있는데 판정이 없으면, 화면은
|
|
582
|
+
* 아무 말도 하지 않고 그것은 「전부 이상 없음」과 **화면상 똑같이 보인다.** 그 침묵이 이 함수가
|
|
583
|
+
* 없앤 것이다.
|
|
584
|
+
*
|
|
585
|
+
* ── 세 갈래로 답한다 (이 함수의 전부) ───────────────────────────────────────
|
|
586
|
+
*
|
|
587
|
+
* 'fail' 기준 하나라도 **분명히** 벗어났다
|
|
588
|
+
* 'pass' **말하는 기준 전부**를 판정했고 전부 안에 있다
|
|
589
|
+
* undefined 판정할 수 없다 — 하나라도 판정 못 한 기준이 있거나, 판정할 기준이 아예 없다
|
|
590
|
+
*
|
|
591
|
+
* `'pass'` 가 「말하는 기준 **전부**」를 요구하는 것이 이 함수의 핵심이다. 하나라도 판정하지 못했는데
|
|
592
|
+
* 합격이라 하면 **확인되지 않은 합격**이 되고, 규제 기록에서 그것은 결함이 아니라 사고다. 그래서
|
|
593
|
+
* 모르는 것이 하나라도 있으면 아무 말도 하지 않는다.
|
|
594
|
+
*
|
|
595
|
+
* 아무 한계도 말하지 않는 기준(`criterionSaysNothing`)은 **셈에서 뺀다** — 자유 문장만 적힌 기준
|
|
596
|
+
* 때문에 판정 가능한 것까지 침묵하면, 이 축이 그 현장에서 영원히 죽는다(고치려던 것의 반대 방향).
|
|
597
|
+
* 그 기준이 비어 있다는 사실은 `testEvidenceGaps` 와 `criterionSaysNothing` 이 따로 낸다 —
|
|
598
|
+
* **설정의 흠과 운영의 사실을 한 답에 섞지 않는다.**
|
|
599
|
+
*
|
|
600
|
+
* 커널은 `Expression` 을 **읽지 않는다**(문법이 정의되지 않은 자유 문자열). 판정은 숫자 한계로만 한다.
|
|
601
|
+
*/
|
|
602
|
+
export declare function judgeAgainstSpec(spec: Pick<TestSpecification, 'criteria'> | undefined, result: Pick<TestResult, 'propertyMeasurements'> | undefined): 'pass' | 'fail' | undefined;
|
|
550
603
|
/**
|
|
551
604
|
* 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
|
|
552
605
|
*
|
|
@@ -1151,6 +1204,39 @@ export interface ItemState {
|
|
|
1151
1204
|
lot?: string;
|
|
1152
1205
|
location: string;
|
|
1153
1206
|
disposition?: string;
|
|
1207
|
+
/**
|
|
1208
|
+
* 이 로트의 **시험 결과** — 명세당 최신 하나.
|
|
1209
|
+
*
|
|
1210
|
+
* ── 로트에 「어느 기준이 걸리나」는 두지 않았다 ────────────────────────────
|
|
1211
|
+
* 표준 `MaterialLotType` 에는 `TestSpecificationID` 가 있다. 우리는 그것을 **로트에 두지 않는다** —
|
|
1212
|
+
* 이미 `TwinModelDef.materialDefinitions[].testSpecificationIds` 가 「이 품목에 무엇이 걸리나」를
|
|
1213
|
+
* 말하고, 물품은 `gtinKey` 로 그 선언에 닿는다. 로트마다 또 적으면 두 벌이 되고, 두 벌은 언젠가
|
|
1214
|
+
* 어긋난다.
|
|
1215
|
+
*
|
|
1216
|
+
* 그리고 **채우는 곳이 없는 칸은 만들지 않는다.** 로트별로 다른 기준을 말하는 원천을 만나면 그때
|
|
1217
|
+
* 열고, 그때는 채우는 코드와 함께 온다 — 선언만 있는 능력은 소비처에게 거짓말이 된다.
|
|
1218
|
+
*
|
|
1219
|
+
* ── 당위 (2026-08-24) ────────────────────────────────────────────────────
|
|
1220
|
+
* 트윈은 「이 자재를 쓸 수 있나」에 답해야 한다. 그 답의 **근거가 측정값**이다. 판정만 들고 값을
|
|
1221
|
+
* 버리면 「합격이라고 적혀 있다」만 남고 **왜 합격인지 되짚을 수 없다** — 규제 기록은 성질상 사고
|
|
1222
|
+
* 뒤에 읽히므로 그때 아무 일도 하지 못한다. 그리고 이 기록을 이어 갈 수 있는 것은 트윈뿐이다:
|
|
1223
|
+
* 로트는 시스템을 넘어 다니고, 판정한 시스템은 그 뒤를 모른다.
|
|
1224
|
+
*
|
|
1225
|
+
* ── 표준에서 왜 여기 있나 (원문 대조) ─────────────────────────────────────
|
|
1226
|
+
* `MaterialLotType`(B2MML-Material.xsd)이 드는 것은 `TestSpecificationID`(어느 기준)와
|
|
1227
|
+
* `Disposition`(판정)이고, **시험 결과는 로트 안에 없다.** 결과는 `TestResultType` 이라는 별개
|
|
1228
|
+
* 기록이고 `TestableObjectID` 로 **대상을 가리킨다**(B2MML-OperationsTest.xsd).
|
|
1229
|
+
*
|
|
1230
|
+
* **그것이 우리가 좁힌 자리다**: 우리 상태는 대상별 투영이라(소비처가 「이 물품은?」을 묻는다) 가리키는
|
|
1231
|
+
* 기록을 개체 안에 접어 둔다. 같은 사실이고 방향만 다르다 — 사건에서는 표준 그대로 대상을 가리킨다
|
|
1232
|
+
* (§`OP_EVENT.test`). 자원에서 이미 같은 좁힘을 했다(`EquipmentState.testResults`).
|
|
1233
|
+
*
|
|
1234
|
+
* ── 명세당 하나인 이유 ────────────────────────────────────────────────────
|
|
1235
|
+
* 이력을 들면 상태가 **계측 주기로 자란다**(품목 100만 기준에서는 그것이 곧 벽이다). 「그때 무엇을
|
|
1236
|
+
* 쟀나」는 저널이 답하는 물음이고, 상태가 답하는 것은 「지금 이 로트의 자격이 무엇이냐」다.
|
|
1237
|
+
* 자리의 관측을 속성당 하나만 든 것과 같은 규율이다(§`LocationState.observations`).
|
|
1238
|
+
*/
|
|
1239
|
+
testResults?: TestResult[];
|
|
1154
1240
|
/** 소속 물류단위(팔레트 SSCC 등) — AggregationEvent 로 맺어진다. 3D 적재 표현의 재료. */
|
|
1155
1241
|
parent?: string;
|
|
1156
1242
|
/**
|
|
@@ -2062,6 +2148,19 @@ export declare const OP_EVENT: {
|
|
|
2062
2148
|
readonly asset: "asset.status";
|
|
2063
2149
|
readonly order: "order.status";
|
|
2064
2150
|
readonly quality: "quality.output";
|
|
2151
|
+
/**
|
|
2152
|
+
* **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
|
|
2153
|
+
*
|
|
2154
|
+
* `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
|
|
2155
|
+
* 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
|
|
2156
|
+
*
|
|
2157
|
+
* **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
|
|
2158
|
+
* 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
|
|
2159
|
+
*
|
|
2160
|
+
* 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
|
|
2161
|
+
* 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
|
|
2162
|
+
*/
|
|
2163
|
+
readonly test: "test.result";
|
|
2065
2164
|
/**
|
|
2066
2165
|
* 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
|
|
2067
2166
|
*
|
package/dist/contract.js
CHANGED
|
@@ -331,6 +331,51 @@ export function testEvidenceGaps(spec, result) {
|
|
|
331
331
|
.filter((k) => !!k && !wanted.has(k))
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* **선언된 기준으로 판정한다** — 원천이 판정하지 않았을 때 커널이 답을 낼 수 있는가.
|
|
336
|
+
*
|
|
337
|
+
* ── 왜 이 함수가 있나 (당위) ────────────────────────────────────────────────
|
|
338
|
+
* 트윈은 **아무도 판정하지 않을 때 판정해야 한다.** 값이 있고 기준이 있는데 판정이 없으면, 화면은
|
|
339
|
+
* 아무 말도 하지 않고 그것은 「전부 이상 없음」과 **화면상 똑같이 보인다.** 그 침묵이 이 함수가
|
|
340
|
+
* 없앤 것이다.
|
|
341
|
+
*
|
|
342
|
+
* ── 세 갈래로 답한다 (이 함수의 전부) ───────────────────────────────────────
|
|
343
|
+
*
|
|
344
|
+
* 'fail' 기준 하나라도 **분명히** 벗어났다
|
|
345
|
+
* 'pass' **말하는 기준 전부**를 판정했고 전부 안에 있다
|
|
346
|
+
* undefined 판정할 수 없다 — 하나라도 판정 못 한 기준이 있거나, 판정할 기준이 아예 없다
|
|
347
|
+
*
|
|
348
|
+
* `'pass'` 가 「말하는 기준 **전부**」를 요구하는 것이 이 함수의 핵심이다. 하나라도 판정하지 못했는데
|
|
349
|
+
* 합격이라 하면 **확인되지 않은 합격**이 되고, 규제 기록에서 그것은 결함이 아니라 사고다. 그래서
|
|
350
|
+
* 모르는 것이 하나라도 있으면 아무 말도 하지 않는다.
|
|
351
|
+
*
|
|
352
|
+
* 아무 한계도 말하지 않는 기준(`criterionSaysNothing`)은 **셈에서 뺀다** — 자유 문장만 적힌 기준
|
|
353
|
+
* 때문에 판정 가능한 것까지 침묵하면, 이 축이 그 현장에서 영원히 죽는다(고치려던 것의 반대 방향).
|
|
354
|
+
* 그 기준이 비어 있다는 사실은 `testEvidenceGaps` 와 `criterionSaysNothing` 이 따로 낸다 —
|
|
355
|
+
* **설정의 흠과 운영의 사실을 한 답에 섞지 않는다.**
|
|
356
|
+
*
|
|
357
|
+
* 커널은 `Expression` 을 **읽지 않는다**(문법이 정의되지 않은 자유 문자열). 판정은 숫자 한계로만 한다.
|
|
358
|
+
*/
|
|
359
|
+
export function judgeAgainstSpec(spec, result) {
|
|
360
|
+
/* 판정할 기준만 남긴다 — 말하지 않는 기준은 이 답에 관여하지 않는다. */
|
|
361
|
+
const criteria = (spec?.criteria ?? []).filter(c => !criterionSaysNothing(c));
|
|
362
|
+
if (!criteria.length)
|
|
363
|
+
return undefined;
|
|
364
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
365
|
+
let allJudged = true;
|
|
366
|
+
for (const c of criteria) {
|
|
367
|
+
/* 그 기준이 재는 속성의 측정값 — 속성을 말하지 않은 기준은 짝을 맞출 수 없다. */
|
|
368
|
+
const m = c.evaluatedPropertyId
|
|
369
|
+
? measurements.find(x => x.testableObjectPropertyId === c.evaluatedPropertyId)
|
|
370
|
+
: undefined;
|
|
371
|
+
const out = outsideLimit(c, m);
|
|
372
|
+
if (out === true)
|
|
373
|
+
return 'fail';
|
|
374
|
+
if (out === undefined)
|
|
375
|
+
allJudged = false;
|
|
376
|
+
}
|
|
377
|
+
return allJudged ? 'pass' : undefined;
|
|
378
|
+
}
|
|
334
379
|
/**
|
|
335
380
|
* 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
|
|
336
381
|
*
|
|
@@ -886,6 +931,19 @@ export const OP_EVENT = {
|
|
|
886
931
|
asset: 'asset.status',
|
|
887
932
|
order: 'order.status',
|
|
888
933
|
quality: 'quality.output', // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
934
|
+
/**
|
|
935
|
+
* **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
|
|
936
|
+
*
|
|
937
|
+
* `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
|
|
938
|
+
* 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
|
|
939
|
+
*
|
|
940
|
+
* **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
|
|
941
|
+
* 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
|
|
942
|
+
*
|
|
943
|
+
* 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
|
|
944
|
+
* 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
|
|
945
|
+
*/
|
|
946
|
+
test: 'test.result',
|
|
889
947
|
/**
|
|
890
948
|
* 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
|
|
891
949
|
*
|
package/dist/face2-adapter.d.ts
CHANGED
|
@@ -29,6 +29,22 @@ export interface ObjectEventMapping {
|
|
|
29
29
|
* 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다(시뮬 `consumeMaterials` 가 택한 것과 같은 규약).
|
|
30
30
|
*/
|
|
31
31
|
quantityList?: MapValue;
|
|
32
|
+
/**
|
|
33
|
+
* **개체·로트의 마스터데이터** → `ilmd`(EPCIS 2.0 Instance/Lot Master Data).
|
|
34
|
+
*
|
|
35
|
+
* ── 왜 뒤늦게 생겼나 (2026-08-24 실측) ─────────────────────────────────────
|
|
36
|
+
* `quantityList` 와 **똑같은 모양의 결함**이었다: 받는 쪽은 처음부터 준비돼 있는데 **문이 없었다.**
|
|
37
|
+
* `ObservedReducer` 는 `ilmd` 를 읽어 로트와 소비기한을 세운다(`expiryOf` · `ILMD_ATTR.lot`), 그리고
|
|
38
|
+
* 이름을 모르는 속성도 버리지 않고 원문으로 든다. 그런데 이 매핑에 자리가 없어서 **커넥터가 그것을
|
|
39
|
+
* 말할 길이 없었다.**
|
|
40
|
+
*
|
|
41
|
+
* 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난
|
|
42
|
+
* 재고 950건·59,785kg 이 어디에도 나타나지 않았다 — 화면이 비어 있는 것이 「이상 없음」으로 읽혔다.
|
|
43
|
+
*
|
|
44
|
+
* 값은 **객체 그대로** 받는다(`{ 'cbvmda:lotNumber': …, 'cbvmda:itemExpirationDate': … }`). 키 어휘는
|
|
45
|
+
* 표준(CBV MDA)의 것이고 커널이 `ILMD_ATTR` 로 그 이름을 안다 — 여기서 이름을 다시 정하지 않는다.
|
|
46
|
+
*/
|
|
47
|
+
ilmd?: MapValue;
|
|
32
48
|
readPoint?: MapValue;
|
|
33
49
|
bizLocation?: MapValue;
|
|
34
50
|
}
|
package/dist/face2-adapter.js
CHANGED
|
@@ -99,6 +99,26 @@ function resolveQuantityList(v, record, field, errors) {
|
|
|
99
99
|
}
|
|
100
100
|
return r.map(x => ({ ...x }));
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* 객체 하나를 그대로 꺼낸다 — `ilmd` 처럼 **구조가 곧 값**인 자리.
|
|
104
|
+
*
|
|
105
|
+
* `resolve` 는 객체가 오면 오류로 본다(스칼라 자리에 표준 구조를 실은 것이 실제 결함이었다). 여기서는
|
|
106
|
+
* 반대다: 객체가 맞고 스칼라가 틀렸다. 그래서 함수를 갈라 두고 각자 자기 모양을 단정한다.
|
|
107
|
+
*/
|
|
108
|
+
function resolveObject(v, record, field, errors) {
|
|
109
|
+
if (v === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
|
|
112
|
+
if (r === undefined || r === null)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (typeof r !== 'object' || Array.isArray(r)) {
|
|
115
|
+
errors.push(`${field} 가 객체가 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const o = { ...r };
|
|
119
|
+
/* 빈 객체는 「말하지 않은 것」이다 — 실으면 소비처가 「마스터데이터가 있는데 비었다」로 읽는다. */
|
|
120
|
+
return Object.keys(o).length ? o : undefined;
|
|
121
|
+
}
|
|
102
122
|
/** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
|
|
103
123
|
export function mapRecordChecked(record, mapping, eventTime) {
|
|
104
124
|
const errors = [];
|
|
@@ -165,11 +185,13 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
165
185
|
if (!epc && !quantityList.length) {
|
|
166
186
|
errors.push('epc 와 quantityList 가 둘 다 비었다 — 개체 식별자나 클래스+수량 중 하나는 있어야 한다');
|
|
167
187
|
}
|
|
188
|
+
const ilmd = resolveObject(mapping.ilmd, record, 'ilmd', errors);
|
|
168
189
|
return {
|
|
169
190
|
event: objectEvent({
|
|
170
191
|
eventTime, action, bizStep, disposition,
|
|
171
192
|
epcList: epc ? [epc] : [],
|
|
172
193
|
...(quantityList.length ? { quantityList } : {}),
|
|
194
|
+
...(ilmd ? { ilmd } : {}),
|
|
173
195
|
readPoint, bizLocation
|
|
174
196
|
}),
|
|
175
197
|
errors
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
|
|
1
|
+
import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
|
|
2
2
|
interface ProjItem {
|
|
3
3
|
epc: string;
|
|
4
4
|
/** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
|
|
@@ -16,6 +16,8 @@ interface ProjItem {
|
|
|
16
16
|
/** 받은 개체·로트 마스터데이터 원문 — 이름을 모르는 속성도 잃지 않는다. */
|
|
17
17
|
ilmd?: Record<string, unknown>;
|
|
18
18
|
expiry?: number;
|
|
19
|
+
/** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
|
|
20
|
+
testResults?: TestResult[];
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* 마스터 동기 — 선언적 로케이션 upsert/remove.
|
|
@@ -177,6 +179,8 @@ export declare class ObservedReducer {
|
|
|
177
179
|
private corrections;
|
|
178
180
|
/** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
|
|
179
181
|
private unhandled;
|
|
182
|
+
/** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
|
|
183
|
+
private testSpecs;
|
|
180
184
|
/**
|
|
181
185
|
* 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
|
|
182
186
|
*
|
package/dist/observed-reducer.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
|
|
20
20
|
* 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
|
|
21
21
|
*/
|
|
22
|
-
import { OP_EVENT, capabilityOf, itemKeyOf, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
|
|
22
|
+
import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
|
|
23
23
|
import { ILMD_ATTR, parseEpc } from "./epcis.js";
|
|
24
24
|
/**
|
|
25
25
|
* 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
|
|
@@ -68,6 +68,8 @@ export class ObservedReducer {
|
|
|
68
68
|
corrections = [];
|
|
69
69
|
/** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
|
|
70
70
|
unhandled = new Map();
|
|
71
|
+
/** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
|
|
72
|
+
testSpecs = new Map();
|
|
71
73
|
/**
|
|
72
74
|
* 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
|
|
73
75
|
*
|
|
@@ -87,6 +89,9 @@ export class ObservedReducer {
|
|
|
87
89
|
constructor(model) {
|
|
88
90
|
this.utcOffsetMinutes = model.utcOffsetMinutes;
|
|
89
91
|
this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
|
|
92
|
+
/* 선언된 판정 기준 — 원천이 판정하지 않은 결과를 커널이 판정할 때 쓴다(§`judgeAgainstSpec`). */
|
|
93
|
+
for (const sp of model.testSpecifications ?? [])
|
|
94
|
+
this.testSpecs.set(sp.id, sp);
|
|
90
95
|
for (const n of readBoardLocations(model))
|
|
91
96
|
this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
|
|
92
97
|
// 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
|
|
@@ -362,6 +367,43 @@ export class ObservedReducer {
|
|
|
362
367
|
this.observations.set(d.locationId, bin);
|
|
363
368
|
break;
|
|
364
369
|
}
|
|
370
|
+
case OP_EVENT.test: {
|
|
371
|
+
/*
|
|
372
|
+
* **시험 결과** — 대상을 가리켜 들어오고(표준 방향), 상태에서는 그 개체 안에 접힌다.
|
|
373
|
+
*
|
|
374
|
+
* 명세당 마지막 하나만 든다: 이력을 들면 상태가 계측 주기로 자란다(품목 100만 기준에서 벽이다).
|
|
375
|
+
* 늦게 온 옛 결과가 최신을 덮지 않게 `stale` 을 지나며, **대상 키에 명세를 넣는다** — 온도
|
|
376
|
+
* 기준의 결과가 늦게 와도 중량 기준의 최신은 지켜져야 한다.
|
|
377
|
+
*
|
|
378
|
+
* ── 물품을 지어내지 않는다 ────────────────────────────────────────────
|
|
379
|
+
* 자리의 관측은 모르는 자리를 세운다(`origin: 'observed'` — 자리는 있는 것이다). **물품은
|
|
380
|
+
* 세우지 않는다**: 없는 재고를 만드는 것이고, 그것은 사실을 잃는 것보다 나쁘다. 그래서 모르는
|
|
381
|
+
* 대상의 결과는 `unhandled` 로 **세어서** 낸다 — 조용히 버리지 않는다.
|
|
382
|
+
*
|
|
383
|
+
* ── 원천이 판정하지 않으면 커널이 판정한다 ──────────────────────────
|
|
384
|
+
* 값이 있고 기준이 선언돼 있는데 판정이 없으면 화면은 침묵하고, 그 침묵은 「이상 없음」과
|
|
385
|
+
* 구별되지 않는다. 그래서 `judgeAgainstSpec` 으로 채우고 **`derived` 를 세운다** — 「현장이
|
|
386
|
+
* 판정했다」와 「트윈이 계산했다」가 섞이면 그 기록은 사고 뒤에 쓸 수 없다.
|
|
387
|
+
* 판정할 수 없으면 비워 둔다(모름을 합격으로 만들지 않는다).
|
|
388
|
+
*/
|
|
389
|
+
const d = e.data;
|
|
390
|
+
if (!d?.testableObjectId || !d?.specId)
|
|
391
|
+
break;
|
|
392
|
+
if (this.stale(`test:${d.testableObjectId}:${d.specId}`, e))
|
|
393
|
+
return;
|
|
394
|
+
const item = this.items.get(d.testableObjectId);
|
|
395
|
+
if (!item)
|
|
396
|
+
break;
|
|
397
|
+
const { testableObjectId: _target, ...rest } = d;
|
|
398
|
+
const judged = rest.result === undefined ? judgeAgainstSpec(this.testSpecs.get(d.specId), rest) : undefined;
|
|
399
|
+
const next = {
|
|
400
|
+
...rest,
|
|
401
|
+
...(judged ? { result: judged, derived: true } : {})
|
|
402
|
+
};
|
|
403
|
+
const kept = (item.testResults ?? []).filter(r => r.specId !== d.specId);
|
|
404
|
+
item.testResults = [...kept, next];
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
365
407
|
case OP_EVENT.attentionAck: {
|
|
366
408
|
/* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
|
|
367
409
|
const d = e.data;
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -132,6 +132,7 @@ __export(index_exports, {
|
|
|
132
132
|
isTransformationRecord: () => isTransformationRecord,
|
|
133
133
|
isoDurationHours: () => isoDurationHours,
|
|
134
134
|
itemKeyOf: () => itemKeyOf,
|
|
135
|
+
judgeAgainstSpec: () => judgeAgainstSpec,
|
|
135
136
|
levelOfLocationType: () => levelOfLocationType,
|
|
136
137
|
lgtinClass: () => lgtinClass,
|
|
137
138
|
locationStatusOf: () => locationStatusOf,
|
|
@@ -325,6 +326,19 @@ function testEvidenceGaps(spec, result) {
|
|
|
325
326
|
unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
|
|
326
327
|
};
|
|
327
328
|
}
|
|
329
|
+
function judgeAgainstSpec(spec, result) {
|
|
330
|
+
const criteria = (spec?.criteria ?? []).filter((c) => !criterionSaysNothing(c));
|
|
331
|
+
if (!criteria.length) return void 0;
|
|
332
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
333
|
+
let allJudged = true;
|
|
334
|
+
for (const c of criteria) {
|
|
335
|
+
const m = c.evaluatedPropertyId ? measurements.find((x) => x.testableObjectPropertyId === c.evaluatedPropertyId) : void 0;
|
|
336
|
+
const out = outsideLimit(c, m);
|
|
337
|
+
if (out === true) return "fail";
|
|
338
|
+
if (out === void 0) allJudged = false;
|
|
339
|
+
}
|
|
340
|
+
return allJudged ? "pass" : void 0;
|
|
341
|
+
}
|
|
328
342
|
function effectivityAt(p, at) {
|
|
329
343
|
if (!p || !at) return void 0;
|
|
330
344
|
const atMs = parsedMs(at);
|
|
@@ -587,6 +601,19 @@ var OP_EVENT = {
|
|
|
587
601
|
order: "order.status",
|
|
588
602
|
quality: "quality.output",
|
|
589
603
|
// 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
604
|
+
/**
|
|
605
|
+
* **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
|
|
606
|
+
*
|
|
607
|
+
* `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
|
|
608
|
+
* 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
|
|
609
|
+
*
|
|
610
|
+
* **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
|
|
611
|
+
* 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
|
|
612
|
+
*
|
|
613
|
+
* 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
|
|
614
|
+
* 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
|
|
615
|
+
*/
|
|
616
|
+
test: "test.result",
|
|
590
617
|
/**
|
|
591
618
|
* 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
|
|
592
619
|
*
|
|
@@ -1357,6 +1384,8 @@ var ObservedReducer = class {
|
|
|
1357
1384
|
corrections = [];
|
|
1358
1385
|
/** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
|
|
1359
1386
|
unhandled = /* @__PURE__ */ new Map();
|
|
1387
|
+
/** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
|
|
1388
|
+
testSpecs = /* @__PURE__ */ new Map();
|
|
1360
1389
|
/**
|
|
1361
1390
|
* 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
|
|
1362
1391
|
*
|
|
@@ -1376,6 +1405,7 @@ var ObservedReducer = class {
|
|
|
1376
1405
|
constructor(model) {
|
|
1377
1406
|
this.utcOffsetMinutes = model.utcOffsetMinutes;
|
|
1378
1407
|
this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
|
|
1408
|
+
for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
|
|
1379
1409
|
for (const n of readBoardLocations(model)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
|
|
1380
1410
|
for (const m of readBoardEquipment(model)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
|
|
1381
1411
|
for (const p of model.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
|
|
@@ -1609,6 +1639,22 @@ var ObservedReducer = class {
|
|
|
1609
1639
|
this.observations.set(d.locationId, bin);
|
|
1610
1640
|
break;
|
|
1611
1641
|
}
|
|
1642
|
+
case OP_EVENT.test: {
|
|
1643
|
+
const d = e.data;
|
|
1644
|
+
if (!d?.testableObjectId || !d?.specId) break;
|
|
1645
|
+
if (this.stale(`test:${d.testableObjectId}:${d.specId}`, e)) return;
|
|
1646
|
+
const item = this.items.get(d.testableObjectId);
|
|
1647
|
+
if (!item) break;
|
|
1648
|
+
const { testableObjectId: _target, ...rest } = d;
|
|
1649
|
+
const judged = rest.result === void 0 ? judgeAgainstSpec(this.testSpecs.get(d.specId), rest) : void 0;
|
|
1650
|
+
const next = {
|
|
1651
|
+
...rest,
|
|
1652
|
+
...judged ? { result: judged, derived: true } : {}
|
|
1653
|
+
};
|
|
1654
|
+
const kept = (item.testResults ?? []).filter((r) => r.specId !== d.specId);
|
|
1655
|
+
item.testResults = [...kept, next];
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1612
1658
|
case OP_EVENT.attentionAck: {
|
|
1613
1659
|
const d = e.data;
|
|
1614
1660
|
if (d?.id) this.acked.add(d.id);
|
|
@@ -3142,6 +3188,17 @@ function resolveQuantityList(v, record, field, errors) {
|
|
|
3142
3188
|
}
|
|
3143
3189
|
return r.map((x) => ({ ...x }));
|
|
3144
3190
|
}
|
|
3191
|
+
function resolveObject(v, record, field, errors) {
|
|
3192
|
+
if (v === void 0) return void 0;
|
|
3193
|
+
const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
|
|
3194
|
+
if (r === void 0 || r === null) return void 0;
|
|
3195
|
+
if (typeof r !== "object" || Array.isArray(r)) {
|
|
3196
|
+
errors.push(`${field} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
|
|
3197
|
+
return void 0;
|
|
3198
|
+
}
|
|
3199
|
+
const o = { ...r };
|
|
3200
|
+
return Object.keys(o).length ? o : void 0;
|
|
3201
|
+
}
|
|
3145
3202
|
function mapRecordChecked(record, mapping, eventTime) {
|
|
3146
3203
|
const errors = [];
|
|
3147
3204
|
const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
|
|
@@ -3205,6 +3262,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
3205
3262
|
if (!epc && !quantityList.length) {
|
|
3206
3263
|
errors.push("epc \uC640 quantityList \uAC00 \uB458 \uB2E4 \uBE44\uC5C8\uB2E4 \u2014 \uAC1C\uCCB4 \uC2DD\uBCC4\uC790\uB098 \uD074\uB798\uC2A4+\uC218\uB7C9 \uC911 \uD558\uB098\uB294 \uC788\uC5B4\uC57C \uD55C\uB2E4");
|
|
3207
3264
|
}
|
|
3265
|
+
const ilmd = resolveObject(mapping.ilmd, record, "ilmd", errors);
|
|
3208
3266
|
return {
|
|
3209
3267
|
event: objectEvent({
|
|
3210
3268
|
eventTime,
|
|
@@ -3213,6 +3271,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
3213
3271
|
disposition,
|
|
3214
3272
|
epcList: epc ? [epc] : [],
|
|
3215
3273
|
...quantityList.length ? { quantityList } : {},
|
|
3274
|
+
...ilmd ? { ilmd } : {},
|
|
3216
3275
|
readPoint,
|
|
3217
3276
|
bizLocation
|
|
3218
3277
|
}),
|
|
@@ -9400,6 +9459,7 @@ function retiredVocabularyIn(line) {
|
|
|
9400
9459
|
isTransformationRecord,
|
|
9401
9460
|
isoDurationHours,
|
|
9402
9461
|
itemKeyOf,
|
|
9462
|
+
judgeAgainstSpec,
|
|
9403
9463
|
levelOfLocationType,
|
|
9404
9464
|
lgtinClass,
|
|
9405
9465
|
locationStatusOf,
|
package/package.json
CHANGED