@operato/ops-contract 0.1.0

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.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/dist/capability.d.ts +142 -0
  3. package/dist/capability.js +127 -0
  4. package/dist/capacity.d.ts +99 -0
  5. package/dist/capacity.js +172 -0
  6. package/dist/contract.d.ts +3557 -0
  7. package/dist/contract.js +1248 -0
  8. package/dist/domain-catalog.d.ts +280 -0
  9. package/dist/domain-catalog.js +322 -0
  10. package/dist/domain-definition.d.ts +356 -0
  11. package/dist/domain-definition.js +137 -0
  12. package/dist/ems-profile.d.ts +147 -0
  13. package/dist/ems-profile.js +367 -0
  14. package/dist/energy-ingest.d.ts +214 -0
  15. package/dist/energy-ingest.js +801 -0
  16. package/dist/epcis.d.ts +458 -0
  17. package/dist/epcis.js +640 -0
  18. package/dist/face2-adapter.d.ts +191 -0
  19. package/dist/face2-adapter.js +284 -0
  20. package/dist/index.d.ts +18 -0
  21. package/dist/index.js +39 -0
  22. package/dist/iso-duration.d.ts +5 -0
  23. package/dist/iso-duration.js +43 -0
  24. package/dist/master-data.d.ts +46 -0
  25. package/dist/master-data.js +100 -0
  26. package/dist/mes-profile.d.ts +14 -0
  27. package/dist/mes-profile.js +59 -0
  28. package/dist/operational-ingest.d.ts +44 -0
  29. package/dist/operational-ingest.js +379 -0
  30. package/dist/operations-capability.d.ts +117 -0
  31. package/dist/operations-capability.js +120 -0
  32. package/dist/scenario-validate.d.ts +15 -0
  33. package/dist/scenario-validate.js +72 -0
  34. package/dist/vocabulary.d.ts +28 -0
  35. package/dist/vocabulary.js +81 -0
  36. package/dist/wms-profile.d.ts +20 -0
  37. package/dist/wms-profile.js +58 -0
  38. package/dist/yms-profile.d.ts +15 -0
  39. package/dist/yms-profile.js +39 -0
  40. package/dist-cjs/index.cjs +3767 -0
  41. package/package.json +30 -0
@@ -0,0 +1,280 @@
1
+ import type { CapabilityKey } from './capability.ts';
2
+ import type { StandardClass } from './domain-definition.ts';
3
+ import type { EquipmentLevel } from './contract.ts';
4
+ /**
5
+ * 트윈의 종류 — 한 현실을 비추는 렌즈의 갈래.
6
+ *
7
+ * `ems` 는 2026-08-14 에 더했다(design/profiles/ems.md). 어휘를 먼저 세우고(2단계) 최소 커널을
8
+ * 그다음에 붙였다(3단계 — `EmsKernel`: 계측 누적 → 구간 마감 → 피크 판정 → 감축 제안).
9
+ *
10
+ * **아직 없는 것**: 요금 구간 전환(요금표는 현장의 것) · 커넥터(4단계 목) · 에너지 전용 화면
11
+ * (§10-6 — 화면은 새로 만들지 않는다). 그리고 **실제 EMS 트윈을 세워 본 적이 없다** — 호스트에
12
+ * 커널은 등록했지만 프로비저닝 템플릿이 없어서, 시험이 검증한 것은 커널의 판정까지다.
13
+ */
14
+ export type DomainSystem = 'wms' | 'yms' | 'mes' | 'ems';
15
+ export interface TwinTypeInfo {
16
+ /** 커널 권위 키 (예: 'storage', 'forklift'). */
17
+ key: string;
18
+ /** 로케이션(수동) vs 자원(능동). */
19
+ role: 'location' | 'equipment';
20
+ label: string;
21
+ /** ① 표준 온톨로지 투영(03-reference-standards). 열린 문자열 — 하드코딩 enum 금지. */
22
+ standardClass: StandardClass;
23
+ /** ④ 식별자 스킴 — id 매칭 UX/검증 힌트(열린 문자열). 예: 'gs1:SGLN' | 'gs1:GIAI' | 'kernel:id'. */
24
+ identity: {
25
+ scheme: string;
26
+ };
27
+ /**
28
+ * 이 자리가 **ISA-95 계층의 몇째 단인가** — `standardClass` 와 **다른 축**이다.
29
+ *
30
+ * · `standardClass.isa95` = 이것이 표준의 **무엇**인가(클래스 투영)
31
+ * · `level` = 그 장소가 계층의 **어디쯤**인가(역할 기반 단)
32
+ *
33
+ * 표준의 총칭 관계를 그대로 쓴다: `WorkCenter` 는 `ProcessCell`·`ProductionLine`·`ProductionUnit`·
34
+ * `StorageZone` 의 총칭이고, `WorkUnit` 은 `Unit`·`WorkCell`·`StorageUnit` 의 총칭이다. 그래서
35
+ * **라인(ProductionLine) ⊃ 스테이션(WorkCell)** 이 되고, 롤업이 단을 근거로 걸을 수 있다.
36
+ *
37
+ * ── 왜 타입이 선언하나 ──────────────────────────────────────────────────────
38
+ * 단은 **타입의 성질**이다(랙은 어느 현장에서나 저장 단위다). 실측(2026-08-14): 로케이션 82개 중
39
+ * `level` 을 선언한 것이 **0개**였고, 그래서 「이 라인의 처리량」을 답하는 `ancestorOfLevel` 이
40
+ * 언제나 `undefined` 였다 — 표준 축이 있는데 데이터가 없어 죽어 있었다. 타입이 선언하면 모델을
41
+ * 하나하나 고치지 않고 살아난다.
42
+ *
43
+ * 설비 타입에는 적지 않는다 — 설비는 자리에 붙박이고, 계층은 자리의 것이다.
44
+ */
45
+ level?: EquipmentLevel;
46
+ /** ② 능력 프로파일(ADR-0018 정제) — 이 타입이 무엇을 하나. 엔티티→capability→컴포넌트 매핑의 SSOT. */
47
+ capabilities: CapabilityKey[];
48
+ }
49
+ export interface DomainProfileInfo {
50
+ system: DomainSystem;
51
+ label: string;
52
+ /** 자리+설비 트윈 타입 — board·UI 팔레트 소싱 SSOT. */
53
+ types: TwinTypeInfo[];
54
+ /** 로케이션 자리 타입 키(하위호환 파생 뷰 = types 중 role==='location'). 신규 소싱은 types 사용. */
55
+ locationTypes: readonly string[];
56
+ }
57
+ export declare const DOMAIN_CATALOG: Record<DomainSystem, DomainProfileInfo>;
58
+ export interface TwinAxisInfo {
59
+ /**
60
+ * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `TwinModelDef` 의 키 **그대로**다
61
+ * (저장·계약의 이름과 어긋나면 그 순간 방언이 생긴다).
62
+ */
63
+ axis: string;
64
+ /**
65
+ * **무엇이 이 축의 항목을 가리키나** — 없으면 소비처가 `id` 를 쓴다.
66
+ *
67
+ * 대부분의 축은 항목마다 `id` 가 있다. 그렇지 않은 축이 있고, 그것이 결함은 아니다 — 수요 구간의
68
+ * 정체성은 **구간이 언제 시작했나**다(요금의 알갱이가 그 시각으로 정해진다).
69
+ *
70
+ * 이 칸이 없던 동안 소비처는 `id`·`key`·`gtin` 을 **짐작**했고, 그래서 화면이 실재하는 항목을
71
+ * 「식별자 없음 — 참조할 수 없는 항목」으로 보였다. 값은 있는데 가리킬 수 없다고 말한 것이다.
72
+ * 짐작을 없애고 선언이 답한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
73
+ *
74
+ * ── 여럿을 받는다 (2026-08-22) ────────────────────────────────────────────
75
+ * 정체성이 **한 칸으로 정해지지 않는 축**이 있다. 물품이 그렇다: 직렬 물품은 `epc` 가 유일하지만,
76
+ * 비직렬 로트가 자리마다 나뉘면 개체를 구별하는 것은 `subLotId` 다(같은 로트의 두 부분은 같은 `epc`
77
+ * 를 갖는다 — §`ItemState.subLotId`). 계약이 그 규칙을 말할 수 없으면 소비처가 다시 짐작한다.
78
+ * 그래서 **차례**를 받는다: 앞에서 값이 있는 첫 칸이 이긴다.
79
+ */
80
+ idField?: string | string[];
81
+ /**
82
+ * 이 축을 **어디서 읽나.**
83
+ *
84
+ * 없으면 `'document'` — 지금까지의 모든 축이 그렇다(선언이고, 인제스트가 쓴다). 그런데 표준이
85
+ * 요구하는 것 중에는 **선언이 아닌 것**이 있다: 작업 일정과 실적은 사람이 적어 두는 값이 아니라
86
+ * 현장이 낳는 사실이고, 그 집은 커널 상태와 저널이다(ISA-95 Part 4).
87
+ *
88
+ * 이 칸이 없으면 축만 늘려도 소비처가 board 에서 찾다가 **언제나 0 을 답한다** — 오류 없이,
89
+ * 그냥 빈 공장처럼. 이 프로젝트가 이미 그 모양으로 한 번 무너졌다(옛 어휘 트윈 모델이 자리 0·설비 0).
90
+ *
91
+ * · `document` — 저장된 트윈 모델 안. `path` 가 그 자리를 말한다
92
+ * · `state` — 커널 상태. **지금의 사실**이고 커널이 돌 때만 있다(멈추면 `null`, 0 이 아니다)
93
+ * · `journal` — 저널. 과거까지 있고 커널이 멈춰도 남는다
94
+ */
95
+ source?: 'document' | 'state' | 'journal';
96
+ /**
97
+ * 이 축이 **시간축을 갖나** — 과거 시점으로 다시 계산할 수 있는가(저널 폴드).
98
+ *
99
+ * 선언은 시간축이 없다(그 시점의 선언은 구조 리비전이 답한다). 관측은 있다 — 그래서 이 표시가
100
+ * 필요하다: 화면이 시각 커서를 이 축에 걸어도 되는지 알아야 한다.
101
+ */
102
+ historical?: boolean;
103
+ /**
104
+ * board 안에서의 자리. 최상위면 `axis` 와 같고, 중첩이면 경로다
105
+ * (`productionSpec.definition.recipes`).
106
+ *
107
+ * **`source` 가 가리키는 자료 안의 경로다** — 예전에는 「`document` 일 때만 있다」고 못 박았는데,
108
+ * 상태 축도 중첩될 수 있다는 것이 에너지에서 드러났다(수요 구간은 `state.energy.closed` 에 산다).
109
+ * 그때 축 이름을 상태의 최상위 키로 맞추려면 같은 배열을 두 자리에 실어야 했다(브로드캐스팅이 그만큼 커진다).
110
+ *
111
+ * 규칙은 하나다: **경로가 있으면 그 경로로 읽고, 없으면 축 이름으로 읽는다.** 빈 문자열로 두지
112
+ * 않는다 — 소비처가 자료의 뿌리를 읽고 통째로 잘못된 답을 만든다.
113
+ *
114
+ * **레시피·라우트는 저장상 `productionSpec` 안에 있지만 개념으로는 1급**이다 — 사람은
115
+ * "레시피" 를 찾지 "생산 정의 안의 레시피" 를 찾지 않는다. 저장 위치가 개념을 가두면
116
+ * 화면이 저장 구조를 흉내 내게 되고, 그건 `ADR-0032`(board 는 캐시) 뒤에 무의미해진다.
117
+ */
118
+ path?: string;
119
+ /** i18n 키 — 사람 언어는 표현 계층이 렌더한다(L2). */
120
+ label: string;
121
+ /**
122
+ * 표준 온톨로지 투영. 표준에 자리가 없으면 **빈 객체**(숨기지 않는다).
123
+ * 키는 표준 하나당 하나다 — 에너지는 ISA-95 가 아니라 ISO 50001·IEC 61850 으로 잰다(`StandardClass`).
124
+ */
125
+ standardClass: StandardClass;
126
+ /**
127
+ * 표준이 **정의(class)와 개체(instance)를 구분한다.** 같은 자재라도 `MaterialDefinition` 과
128
+ * `MaterialLot` 은 다른 것이다. 화면이 둘을 같은 칸에 담지 않도록 선언에 싣는다.
129
+ * `spec` 은 자원이 아니라 **명세**(공정·생산 정의).
130
+ */
131
+ kind: 'instance' | 'class' | 'spec';
132
+ /** 이 축의 항목이 카탈로그 타입을 갖는가 — 있으면 `types` 와 이어진다(자리·설비만). */
133
+ typeRole?: 'location' | 'equipment';
134
+ /**
135
+ * 이 축이 **어느 종류의 트윈에 있는가** — 선언하지 않으면 **전부**에 있다.
136
+ *
137
+ * ── 왜 생겼나 (2026-08-14) ─────────────────────────────────────────────
138
+ * 지금까지 모든 축은 모든 종류에 있다고 **암묵적으로 가정**했다. 창고·야드·공정은 셋 다 로트와
139
+ * 오더와 작업을 가지므로 그 가정이 아프지 않았다. 에너지(EMS)가 처음으로 그것을 깬다 —
140
+ * 옮겨 다니는 물건이 없다(스칼라가 시간 위에서 변한다).
141
+ *
142
+ * 선언하지 않으면 화면은 **없는 것과 해당 없는 것을 구별할 수 없다.** 개념 지도는 「로트 0」을
143
+ * 사실처럼 그리고, 적합성 표는 채우지 못한 것을 결손으로 센다 — 둘 다 거짓말이고, 이 프로젝트가
144
+ * 관측 열에서 내내 지켜 온 구분(`observedAbsence`·`emptyBecause`)과 같은 부류의 실패다.
145
+ *
146
+ * **비워 두는 것이 기본이다**: 대부분의 축은 모든 종류에 있고, 예외만 적는다.
147
+ */
148
+ systems?: DomainSystem[];
149
+ }
150
+ /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
151
+ export declare const TWIN_AXES: TwinAxisInfo[];
152
+ export type TwinRelationTarget = {
153
+ kind: 'axis';
154
+ axis: string;
155
+ } | {
156
+ kind: 'type';
157
+ role: 'location' | 'equipment';
158
+ }
159
+ /**
160
+ * **커널 밖**을 가리키는 참조. 커널은 대상의 이름만 말하고 해소는 호스트가 한다.
161
+ *
162
+ * 실물: `locations[].parentId` 는 자리가 아니라 **공간의 구역**을 가리킨다. 마스터에서는
163
+ * 구역도 자리와 같은 목록에 있다가(`role: 'area'`) 인제스트가 갈라 `TwinArea` 로 보내기 때문에,
164
+ * board 안에는 **대상이 없는 참조**가 남는다. 이것을 `axis: 'locations'` 로 적으면 선언이
165
+ * 거짓말을 하고 지도에 없는 선이 생긴다 — 실제로 그렇게 적었다가 실 데이터로 걸어 보고 잡았다.
166
+ *
167
+ * 커널이 `TwinArea` 를 알 수는 없다(공간은 호스트 개념). 그래서 **모른다는 사실을 선언한다.**
168
+ */
169
+ | {
170
+ kind: 'external';
171
+ entity: string;
172
+ };
173
+ export interface TwinRelationInfo {
174
+ /** 출발 축. */
175
+ from: string;
176
+ /**
177
+ * 그 축의 항목에서 대상 키를 꺼내는 경로. `[]` 는 **배열 펼침**이다.
178
+ * 예: `route` · `steps[]` · `inputs[].material`.
179
+ */
180
+ field: string;
181
+ target: TwinRelationTarget;
182
+ /** 관계의 이름(i18n 키) — 화면의 선과 이웃 묶음에 붙는다. */
183
+ via: string;
184
+ /** 없을 수 있는 참조인가. `false` 인데 비면 **끊어진 참조**로 보고한다. */
185
+ optional?: boolean;
186
+ }
187
+ /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
188
+ export declare const TWIN_RELATIONS: TwinRelationInfo[];
189
+ export type TwinObservationKind =
190
+ /** 여러 번 일어난 일의 **퍼짐**. 중앙값 + p10·p90 + 표본 수. 예: 공정 소요, 체류시간. */
191
+ 'distribution'
192
+ /** 어느 시점의 **차 있음**. 현재 · 최대 · 평균. 예: 자리 점유, 재고. */
193
+ | 'level'
194
+ /** 창(window)당 **비(比)**. 값 + 추세. 예: 가동률, 처리량. */
195
+ | 'rate'
196
+ /** 시간축 위의 **연속 계측**. 스파크라인 + 구간 통계. 예: 부하곡선, 온도. */
197
+ | 'series'
198
+ /** 벌어진 **횟수**. 누계 + 기간. 예: 고장 횟수, 완료 건수. */
199
+ | 'count';
200
+ /**
201
+ * 한 축의 항목이 갖는 **볼 수 있는 속성** 하나.
202
+ *
203
+ * 선언값(원본이 말한 것)과 관측값(트윈이 잰 것)은 **둘 다 없을 수 있다.**
204
+ * · 선언만 있음 — 원본이 말했는데 아직 못 쟀다(표본 부족)
205
+ * · 관측만 있음 — 원본이 침묵한 자리를 트윈이 채우고 있다. **트윈을 가진 이유가 여기 있다**
206
+ * · 둘 다 있고 어긋남 — 원본의 표준값이 이 현장과 다르다는 **발견**
207
+ * 그래서 어느 쪽도 없다고 숨기지 않는다. 없으면 `—` 로 낸다.
208
+ */
209
+ export interface TwinPropertyInfo {
210
+ /** 어느 축의 속성인가. */
211
+ axis: string;
212
+ /** 속성 키 — 화면·질의가 쓰는 이름. */
213
+ key: string;
214
+ label: string;
215
+ kind: TwinObservationKind;
216
+ /**
217
+ * 선언값이 항목의 어느 필드에 있나. **없으면 원본이 말하지 않는 속성**이다
218
+ * (그래도 관측은 있을 수 있다 — 위 두 번째 경우).
219
+ */
220
+ declaredField?: string;
221
+ /**
222
+ * 이 관측을 **누가 내나.** 커널·호스트의 실제 산출 이름을 적는다. 지어낸 관측을 선언하지
223
+ * 않기 위한 표식이다 — 없으면 아직 아무도 안 낸다는 뜻이고, 화면은 `—` 를 낸다.
224
+ */
225
+ observedBy?: string;
226
+ /** 단위(UN/CEFACT 공통코드 또는 `ms`·`ratio`). **단위 없는 물리량은 쓰지 않는다**(계약의 규율). */
227
+ uom?: string;
228
+ }
229
+ /**
230
+ * 오늘 **실제로 짝이 서는 것**만 선언한다. 그럴듯한 속성을 미리 적어 두면 화면이 빈칸을 줄줄이
231
+ * 내고, 그건 "아직 안 쟀다" 가 아니라 "이 트윈은 부실하다" 로 읽힌다.
232
+ * `series` 는 아직 **아무도 내지 않는다** — EMS 가 붙을 때 생산자와 함께 선언한다.
233
+ */
234
+ export declare const TWIN_PROPERTIES: TwinPropertyInfo[];
235
+ /** 이 축이 보여 줄 속성들 — 없으면 빈 배열(속성 없는 축이 정상이다). */
236
+ export declare const propertiesOf: (axis: string) => TwinPropertyInfo[];
237
+ /**
238
+ * 이 축은 **선언에서 읽나** — `source` 를 말하지 않은 축은 문서 축이다(기존 14축).
239
+ *
240
+ * 소비처가 `source` 를 직접 비교하면 기본값 규칙이 자리마다 복사되고, 한 곳이 빠지면 그 축은
241
+ * board 루트를 읽어 조용히 잘못된 답을 낸다. 판정은 여기 하나다.
242
+ */
243
+ export declare const axisSource: (axis: string) => "document" | "state" | "journal";
244
+ /**
245
+ * 문서에서 이 축을 찾을 자리 — **문서 축이 아니면 오류를 낸다.**
246
+ *
247
+ * 상태·저널 축에는 board 안의 자리가 없다. 그런데 옛 소비처는 `info.path` 를 무조건 읽었으므로,
248
+ * 그 자리가 `undefined` 면 board 루트를 읽고 **오류 없이 0 을 답한다**(이 프로젝트가 겪은 실패 모양).
249
+ * 그래서 조용히 넘기지 않고 부르는 쪽을 멈춘다.
250
+ */
251
+ export declare function documentPath(axis: string): string;
252
+ /** 이 축에서 나가는 관계 · 이 축으로 들어오는 관계 — 항목 패널의 "이어진 것" 양방향. */
253
+ export declare const relationsFrom: (axis: string) => TwinRelationInfo[];
254
+ export declare const relationsTo: (axis: string) => TwinRelationInfo[];
255
+ /** 축 이름 → 서술. 모르는 축이면 `undefined` — 화면은 그것을 "미선언" 으로 낸다(숨기지 않는다). */
256
+ export declare function axisInfo(axis: string): TwinAxisInfo | undefined;
257
+ export declare const DOMAIN_SYSTEMS: DomainSystem[];
258
+ /**
259
+ * 이 축이 그 종류의 트윈에 **있는가** — 판정은 여기 하나다.
260
+ *
261
+ * 소비처가 `info.systems` 를 직접 비교하면 "선언하지 않으면 전부" 라는 기본값 규칙이 자리마다
262
+ * 복사되고, 한 곳이 빠지면 그 화면만 다른 답을 낸다(축 하나가 어떤 화면에서는 보이고 어떤
263
+ * 화면에서는 안 보인다). `axisSource` 와 같은 자리, 같은 이유다.
264
+ *
265
+ * **종류를 모르면 있다고 답한다** — 종류를 모르는 채로 축을 숨기면, 화면은 "해당 없음" 과
266
+ * "아직 모름" 을 같게 그리게 된다. 모르는 것을 근거로 감추지 않는다.
267
+ */
268
+ export declare const axisAppliesTo: (axis: string, system?: string) => boolean;
269
+ /**
270
+ * 자리 타입 → **ISA-95 계층 단.** `hierarchyOf` 에 주입해 모델이 단을 적지 않아도 사슬을 걷게 한다.
271
+ *
272
+ * 종류를 알면 그 카탈로그만 본다. 모르면 **모든 종류에서 찾고, 답이 하나일 때만 답한다** — 두 종류가
273
+ * 같은 키를 다른 단으로 선언했다면 그중 하나를 고르는 것은 짐작이고, 짐작한 단으로 롤업하면 그
274
+ * 트윈의 집계가 조용히 틀린다. 그럴 때는 `undefined`(모른다)이고, 시험이 그 충돌 자체를 막는다.
275
+ */
276
+ export declare const levelOfLocationType: (typeKey: string, system?: string) => EquipmentLevel | undefined;
277
+ /** 그 종류의 트윈이 갖는 축들 — 개념 지도·적합성 표가 이 목록으로 그린다. */
278
+ export declare const axesOfSystem: (system?: string) => TwinAxisInfo[];
279
+ /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
280
+ export declare function capabilitiesForType(system: DomainSystem, typeKey: string): CapabilityKey[];
@@ -0,0 +1,322 @@
1
+ import { WMS_TYPES } from "./wms-profile.js";
2
+ import { YMS_TYPES } from "./yms-profile.js";
3
+ import { MES_TYPES } from "./mes-profile.js";
4
+ import { EMS_TYPES } from "./ems-profile.js";
5
+ const locationKeys = (types) => types.filter(t => t.role === 'location').map(t => t.key);
6
+ export const DOMAIN_CATALOG = {
7
+ // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
8
+ wms: { system: 'wms', label: 'twin.system.wms', types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
9
+ yms: { system: 'yms', label: 'twin.system.yms', types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
10
+ mes: { system: 'mes', label: 'twin.system.mes', types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) },
11
+ ems: { system: 'ems', label: 'twin.system.ems', types: EMS_TYPES, locationTypes: locationKeys(EMS_TYPES) }
12
+ };
13
+ /**
14
+ * 물건·사람·공정이 있는 종류 — **에너지에는 없는 축들**이 이것을 선언한다.
15
+ *
16
+ * ── 무엇을 근거로 뺐나 (2026-08-14) ─────────────────────────────────────────
17
+ * 에너지 트윈에는 옮겨 다니는 물건이 없고(자재·로트·오더), 작업을 배정할 사람도 없다(감축은 제안이고
18
+ * 집행은 사람이 자기 시스템에서 한다), 공정도 없다(레시피·라우트·공정 단계).
19
+ *
20
+ * ── 무엇을 일부러 남겼나 ────────────────────────────────────────────────────
21
+ * · `locations`·`equipment` — 자리는 전기적 구간이고 설비는 계량 지점이다. 뜻이 다르지만 **있다**.
22
+ * · `assets`·`assetClasses`·`equipmentClasses` — 계량기·차단기·PV·배터리는 자산이고(ISO 55000),
23
+ * ISO 50001 의 SEU 는 설비 등급으로 대응한다(`ems.md` §2). 있을 수 있는데 아직 없는 것은
24
+ * **「없음」이고 그것은 정직한 0이다** — 「해당 없음」이 아니다.
25
+ * · `testSpecifications` — 계량기 검정·교정이 이 모양에 맞는다. 애매하면 **선언하지 않는다**:
26
+ * 기본값이 「있다」이므로 감추는 쪽이 아니라 보이는 쪽으로 기운다(`axisAppliesTo` 규율).
27
+ *
28
+ * ── 이 목록은 「물류 전용」이 아니라 「에너지에 없음」이다 ────────────────────
29
+ * `recipes`·`routes` 는 사실 MES 의 것이고 창고·야드에는 없다. 그것까지 좁히는 것은 **다른 판단**이고
30
+ * 지금 거동을 바꾼다(창고 트윈에서 카드가 사라진다). 이번에는 EMS 에서 빼는 것만 한다.
31
+ */
32
+ const LOGISTICS = ['wms', 'yms', 'mes'];
33
+ /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
34
+ export const TWIN_AXES = [
35
+ { axis: 'locations', path: 'locations', label: 'twin.axis.locations', kind: 'instance', typeRole: 'location',
36
+ standardClass: { epcis: 'SGLN' } },
37
+ { axis: 'equipment', path: 'equipment', label: 'twin.axis.equipment', kind: 'instance', typeRole: 'equipment',
38
+ standardClass: { isa95: 'Equipment', iso55000: 'Asset', epcis: 'GIAI' } },
39
+ { axis: 'persons', path: 'persons', label: 'twin.axis.persons', kind: 'instance',
40
+ standardClass: { isa95: 'Person' }, systems: LOGISTICS },
41
+ { axis: 'assets', path: 'assets', label: 'twin.axis.assets', kind: 'instance',
42
+ standardClass: { isa95: 'PhysicalAsset', iso55000: 'Asset' } },
43
+ { axis: 'personnelClasses', path: 'personnelClasses', label: 'twin.axis.personnelClasses', kind: 'class',
44
+ standardClass: { isa95: 'PersonnelClass' }, systems: LOGISTICS },
45
+ { axis: 'equipmentClasses', path: 'equipmentClasses', label: 'twin.axis.equipmentClasses', kind: 'class',
46
+ standardClass: { isa95: 'EquipmentClass' } },
47
+ { axis: 'assetClasses', path: 'assetClasses', label: 'twin.axis.assetClasses', kind: 'class',
48
+ standardClass: { isa95: 'PhysicalAssetClass' } },
49
+ { axis: 'materialDefinitions', path: 'materialDefinitions', label: 'twin.axis.materialDefinitions', kind: 'class',
50
+ standardClass: { isa95: 'MaterialDefinition' }, systems: LOGISTICS },
51
+ { axis: 'materialClasses', path: 'materialClasses', label: 'twin.axis.materialClasses', kind: 'class',
52
+ standardClass: { isa95: 'MaterialClass' }, systems: LOGISTICS },
53
+ { axis: 'operations', path: 'operations', label: 'twin.axis.operations', kind: 'spec',
54
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
55
+ { axis: 'productionSpec', path: 'productionSpec', label: 'twin.axis.productionSpec', kind: 'spec',
56
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
57
+ /*
58
+ * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
59
+ * `materials` 가 최상위 `materialDefinitions` 와 **둘 다** 있는 것은 지금 상태 그대로다 —
60
+ * 전자는 도메인 정의의 가벼운 목록(`{key,label}`), 후자는 ISA-95 풍부형이다. 같은 것의 집이
61
+ * 둘이라는 사실을 **선언이 드러낸다**(감추면 화면이 어느 쪽을 보는지 아무도 모른다).
62
+ */
63
+ { axis: 'recipes', path: 'productionSpec.definition.recipes', label: 'twin.axis.recipes', kind: 'spec',
64
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
65
+ { axis: 'routes', path: 'productionSpec.definition.routes', label: 'twin.axis.routes', kind: 'spec',
66
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
67
+ { axis: 'materials', path: 'productionSpec.definition.materials', label: 'twin.axis.materials', kind: 'class',
68
+ standardClass: { isa95: 'MaterialDefinition' }, systems: LOGISTICS },
69
+ /*
70
+ * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
71
+ *
72
+ * 이 축이 없던 동안 `testSpecificationIds` 는 허공을 가리켰다(값은 실리는데 무엇인지 아무도 모른다).
73
+ * 축이 서면 인스펙션의 선 채움이 **끊어진 참조를 센다** — 없는 명세를 가리키면 화면이 말한다.
74
+ *
75
+ * `kind: 'spec'` 이다: 자원도 등급도 아니고 **판정의 정의**다(공정·레시피와 같은 부류).
76
+ */
77
+ { axis: 'testSpecifications', path: 'testSpecifications', label: 'twin.axis.testSpecifications', kind: 'spec',
78
+ standardClass: { isa95: 'TestSpecification' } },
79
+ /*
80
+ * ── ISA-95 Part 4 — 일정과 실적 ────────────────────────────────────────────────
81
+ *
82
+ * 여기부터는 **선언이 아니다.** 오더와 작업은 사람이 board 에 적어 두는 값이 아니라 현장이 낳는
83
+ * 사실이고, 그 집은 커널 상태와 저널이다. 그래서 `source` 가 `'state'` 이고 `path` 가 없다.
84
+ *
85
+ * ── 왜 축이어야 하나 ──────────────────────────────────────────────────────────
86
+ * 이 둘이 축이 아니던 동안 "이 트윈이 표준의 어디를 채우나" 라는 질문은 **절반만** 답했다. Part 2
87
+ * 자원 모델은 여덟이 완전한데 Part 4 는 통째로 비어 있었다 — 런타임에는 실재하는데 조회 모델에는
88
+ * 없었기 때문이다. 실적을 성과 화면이 답한다는 것은 사실이지만, 그건 **집계**이고 개념 자체를
89
+ * 가리켜 걸어 들어갈 자리는 없었다(어느 오더가 어느 공정에서 무엇을 먹었나).
90
+ *
91
+ * ── 왜 테이블로 투영하지 않나 ─────────────────────────────────────────────────
92
+ * 정본이 저널이다. 구조 엔티티는 **원본이 낼 수 있는 것만** 담고(ADR-0032-D), 오더는 원본이 낸
93
+ * 것이지만 트윈이 **관측한** 것이다. 투영은 규모에서 성능이 필요해질 때 KPI 폴드와 같은 급의
94
+ * 별도 결정으로 한다.
95
+ */
96
+ { axis: 'orders', label: 'twin.axis.orders', kind: 'instance', source: 'state', historical: true,
97
+ standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' }, systems: LOGISTICS },
98
+ { axis: 'tasks', label: 'twin.axis.tasks', kind: 'instance', source: 'state', historical: true,
99
+ standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS },
100
+ /*
101
+ * ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
102
+ * 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
103
+ * 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
104
+ * 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
105
+ *
106
+ * 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
107
+ * 그래서 같은 조합(`instance` · `state` · `historical`)이다.
108
+ *
109
+ * ── 표준 대응 ─────────────────────────────────────────────────────────────
110
+ * ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
111
+ * 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
112
+ * 관측되고 사라지는 것을 그 사건이 말한다.
113
+ *
114
+ * ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
115
+ * `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
116
+ * 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
117
+ */
118
+ { axis: 'items', label: 'twin.axis.items', kind: 'instance', source: 'state', historical: true,
119
+ idField: ['subLotId', 'epc'],
120
+ standardClass: { isa95: 'MaterialLot', epcis: 'ObjectEvent' }, systems: LOGISTICS },
121
+ /*
122
+ * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
123
+ *
124
+ * 에너지 트윈의 개체 대부분은 **이미 있는 축**이 답한다: 전기 구간은 `locations`, 계량기·차단기·
125
+ * 태양광·축전지·감축 부하는 `equipment` 다(카탈로그가 그 타입들을 그 역할로 선언한다). 그것들을
126
+ * 새 축으로 다시 세우면 같은 것이 두 곳에서 세어진다 — 개념 지도가 계량기를 두 번 보여 준다.
127
+ *
128
+ * 정말로 새로운 것은 **수요 구간**이다: 자원이 아니고, 선언이 아니고, 15분마다 닫히는 **사실**이다.
129
+ * 그것이 요금의 알갱이이고 피크의 근거다(피크는 마감된 구간의 최대이므로 파생이다 — 축이 아니다).
130
+ *
131
+ * ── 표준 칸을 비운다 ──────────────────────────────────────────────────────
132
+ * 15분 수요 구간은 **요금 제도의 알갱이**다(계약·TOU). ISO 50001 은 경영 체계를, IEC 61850 은 설비
133
+ * 데이터 모델을 말하고, 둘 다 이 구간을 정의하지 않는다. 가까운 이름을 적으면 적합성 표가 거짓을
134
+ * 말하므로 비워 둔다 — 「표준에 자리가 없으면 빈 객체」라는 이 선언의 규율 그대로다.
135
+ *
136
+ * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
137
+ * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
138
+ */
139
+ /*
140
+ * **무엇이 이 항목을 가리키나** — `idField` 가 말한다.
141
+ *
142
+ * 수요 구간에는 `id` 가 없다. 그것이 결함은 아니다: 구간의 정체성은 **그 구간이 언제 시작했나**이고
143
+ * (요금의 알갱이가 그 시각으로 정해진다) 커널도 그것으로 키를 만든다(`contract-projected-over:<startMs>`).
144
+ *
145
+ * 그런데 소비처가 `id`·`key`·`gtin` 을 **짐작**하고 있어서, 화면이 여섯 구간 모두를 「식별자 없음 —
146
+ * 참조할 수 없는 항목」으로 보였다. 값은 실재하는데 가리킬 수 없다고 말한 것이다. 짐작을 없애고
147
+ * 선언이 답하게 한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
148
+ */
149
+ { axis: 'demandWindows', path: 'energy.closed', idField: 'startMs', label: 'twin.axis.demandWindows', kind: 'instance',
150
+ source: 'state', historical: true, standardClass: {}, systems: ['ems'] }
151
+ ];
152
+ /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
153
+ export const TWIN_RELATIONS = [
154
+ /* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
155
+ { from: 'locations', field: 'parentId', target: { kind: 'external', entity: 'space.area' }, via: 'twin.rel.area', optional: true },
156
+ /* 자원이 사는 자리 */
157
+ { from: 'equipment', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
158
+ { from: 'persons', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
159
+ { from: 'assets', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
160
+ /* 자원의 등급 — 설비만 `kind`(타입)로 가고 사람·자산은 등급 목록으로 간다. 표준의 비대칭 그대로. */
161
+ { from: 'persons', field: 'personnelClassIds[]', target: { kind: 'axis', axis: 'personnelClasses' }, via: 'twin.rel.class', optional: true },
162
+ { from: 'assets', field: 'assetClassIds[]', target: { kind: 'axis', axis: 'assetClasses' }, via: 'twin.rel.class', optional: true },
163
+ { from: 'equipment', field: 'kind', target: { kind: 'type', role: 'equipment' }, via: 'twin.rel.type' },
164
+ { from: 'locations', field: 'type', target: { kind: 'type', role: 'location' }, via: 'twin.rel.type' },
165
+ /* 생산 — 레시피에서 시작해 라우트·공정·품목으로 퍼진다 */
166
+ { from: 'recipes', field: 'route', target: { kind: 'axis', axis: 'routes' }, via: 'twin.rel.route', optional: true },
167
+ { from: 'recipes', field: 'inputs[].material', target: { kind: 'axis', axis: 'materials' }, via: 'twin.rel.input' },
168
+ { from: 'recipes', field: 'outputs[].material', target: { kind: 'axis', axis: 'materials' }, via: 'twin.rel.output' },
169
+ { from: 'routes', field: 'steps[]', target: { kind: 'axis', axis: 'operations' }, via: 'twin.rel.step' },
170
+ /*
171
+ * 일정·실적의 관계 — **관측 관계다.** 선언 관계는 "그렇게 만들기로 했다" 이고, 이것은 "그렇게
172
+ * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 연결되지 않은 카드가 된다(그러면 걸어 들어갈 수 없다).
173
+ */
174
+ /*
175
+ * 오더가 무엇을 만드나 — **자재 키가 아니라 GS1 품목 참조다**(`lines[].gtin`). 자재와 이어 주는
176
+ * 것은 `productionSpec.binding` 이고 그 해소는 호스트가 한다 — 자리·구역과 같은 `external` 이다.
177
+ * 축을 직접 가리키게 적으면 없는 필드를 가리키는 선언이 된다(계약을 보고 고쳤다).
178
+ */
179
+ { from: 'orders', field: 'lines[].gtin', target: { kind: 'external', entity: 'gs1.itemRef' }, via: 'twin.rel.item', optional: true },
180
+ { from: 'tasks', field: 'orderId', target: { kind: 'axis', axis: 'orders' }, via: 'twin.rel.order', optional: true },
181
+ /* 작업의 `kind` 는 그 공정의 키다(라우트의 단계 이름 그대로 — `mes-kernel` 의 공정 목록이 그 증거). */
182
+ { from: 'tasks', field: 'kind', target: { kind: 'axis', axis: 'operations' }, via: 'twin.rel.operation', optional: true },
183
+ { from: 'tasks', field: 'fromNode', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.from', optional: true },
184
+ { from: 'tasks', field: 'toNode', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at', optional: true },
185
+ { from: 'tasks', field: 'resourceRef', target: { kind: 'axis', axis: 'equipment' }, via: 'twin.rel.by', optional: true },
186
+ { from: 'tasks', field: 'personnel[]', target: { kind: 'axis', axis: 'persons' }, via: 'twin.rel.crew', optional: true },
187
+ /*
188
+ * 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
189
+ *
190
+ * `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
191
+ * 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
192
+ *
193
+ * `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
194
+ * `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
195
+ *
196
+ * 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
197
+ *
198
+ * 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
199
+ * 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
200
+ */
201
+ { from: 'items', field: 'location', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at' },
202
+ { from: 'items', field: 'parent', target: { kind: 'axis', axis: 'items' }, via: 'twin.rel.parent', optional: true },
203
+ { from: 'items', field: 'carriedBy', target: { kind: 'axis', axis: 'assets' }, via: 'twin.rel.asset', optional: true },
204
+ { from: 'items', field: 'gtin', target: { kind: 'external', entity: 'gs1.itemRef' }, via: 'twin.rel.item', optional: true },
205
+ /*
206
+ * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
207
+ * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
208
+ *
209
+ * **자리(`locations`)에는 없다** — 표준이 자리를 시험 대상으로 두지 않았고 그 비대칭은 표준의
210
+ * 판단이다(`contract.ts` 가 "우리도 자리에 넣지 않는다" 고 적었다).
211
+ */
212
+ { from: 'persons', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
213
+ { from: 'personnelClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
214
+ { from: 'equipment', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
215
+ { from: 'equipmentClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
216
+ { from: 'assets', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
217
+ { from: 'assetClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
218
+ { from: 'materialDefinitions', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
219
+ { from: 'materialClasses', field: 'testSpecificationIds[]', target: { kind: 'axis', axis: 'testSpecifications' }, via: 'twin.rel.test', optional: true },
220
+ /* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
221
+ { from: 'operations', field: 'locationType', target: { kind: 'type', role: 'location' }, via: 'twin.rel.at', optional: true },
222
+ { from: 'operations', field: 'resourceType', target: { kind: 'type', role: 'equipment' }, via: 'twin.rel.by', optional: true }
223
+ ];
224
+ /**
225
+ * 오늘 **실제로 짝이 서는 것**만 선언한다. 그럴듯한 속성을 미리 적어 두면 화면이 빈칸을 줄줄이
226
+ * 내고, 그건 "아직 안 쟀다" 가 아니라 "이 트윈은 부실하다" 로 읽힌다.
227
+ * `series` 는 아직 **아무도 내지 않는다** — EMS 가 붙을 때 생산자와 함께 선언한다.
228
+ */
229
+ export const TWIN_PROPERTIES = [
230
+ /* 공정 — 선언(ISO 8601 기간)과 관측(저널에서 계산한 분포)이 둘 다 있는 유일한 짝. */
231
+ { axis: 'operations', key: 'duration', label: 'twin.prop.duration', kind: 'distribution',
232
+ declaredField: 'duration', observedBy: 'kpi-fold.workTime', uom: 'ms' },
233
+ /* 자리 — 용량은 선언, 점유는 관측. 단위가 다른 것이 아니라 **같은 축의 두 값**이다. */
234
+ { axis: 'locations', key: 'capacity', label: 'twin.prop.capacity', kind: 'level',
235
+ declaredField: 'capacity', observedBy: 'state.location.occupancy' },
236
+ /* 설비 — 고장 모델은 선언, 가동은 관측(OEE). 선언 없이도 관측은 선다. */
237
+ { axis: 'equipment', key: 'mtbf', label: 'twin.prop.mtbf', kind: 'distribution',
238
+ declaredField: 'mtbfMs', observedBy: 'oee.availability', uom: 'ms' },
239
+ { axis: 'equipment', key: 'mttr', label: 'twin.prop.mttr', kind: 'distribution',
240
+ declaredField: 'mttrMs', observedBy: 'oee.availability', uom: 'ms' },
241
+ { axis: 'equipment', key: 'availability', label: 'twin.prop.availability', kind: 'rate',
242
+ observedBy: 'oee.availability', uom: 'ratio' }
243
+ ];
244
+ /** 이 축이 보여 줄 속성들 — 없으면 빈 배열(속성 없는 축이 정상이다). */
245
+ export const propertiesOf = (axis) => TWIN_PROPERTIES.filter(p => p.axis === axis);
246
+ /**
247
+ * 이 축은 **선언에서 읽나** — `source` 를 말하지 않은 축은 문서 축이다(기존 14축).
248
+ *
249
+ * 소비처가 `source` 를 직접 비교하면 기본값 규칙이 자리마다 복사되고, 한 곳이 빠지면 그 축은
250
+ * board 루트를 읽어 조용히 잘못된 답을 낸다. 판정은 여기 하나다.
251
+ */
252
+ export const axisSource = (axis) => TWIN_AXES.find(a => a.axis === axis)?.source ?? 'document';
253
+ /**
254
+ * 문서에서 이 축을 찾을 자리 — **문서 축이 아니면 오류를 낸다.**
255
+ *
256
+ * 상태·저널 축에는 board 안의 자리가 없다. 그런데 옛 소비처는 `info.path` 를 무조건 읽었으므로,
257
+ * 그 자리가 `undefined` 면 board 루트를 읽고 **오류 없이 0 을 답한다**(이 프로젝트가 겪은 실패 모양).
258
+ * 그래서 조용히 넘기지 않고 부르는 쪽을 멈춘다.
259
+ */
260
+ export function documentPath(axis) {
261
+ const info = TWIN_AXES.find(a => a.axis === axis);
262
+ if (!info)
263
+ throw new Error(`unknown twin axis: "${axis}"`);
264
+ if (axisSource(axis) !== 'document' || !info.path)
265
+ throw new Error(`axis "${axis}" is not read from the document (source: ${axisSource(axis)}) — read it from the ${axisSource(axis)}`);
266
+ return info.path;
267
+ }
268
+ /** 이 축에서 나가는 관계 · 이 축으로 들어오는 관계 — 항목 패널의 "이어진 것" 양방향. */
269
+ export const relationsFrom = (axis) => TWIN_RELATIONS.filter(r => r.from === axis);
270
+ export const relationsTo = (axis) => TWIN_RELATIONS.filter(r => r.target.kind === 'axis' && r.target.axis === axis);
271
+ /** 축 이름 → 서술. 모르는 축이면 `undefined` — 화면은 그것을 "미선언" 으로 낸다(숨기지 않는다). */
272
+ export function axisInfo(axis) {
273
+ return TWIN_AXES.find(a => a.axis === axis);
274
+ }
275
+ export const DOMAIN_SYSTEMS = ['wms', 'yms', 'mes', 'ems'];
276
+ /**
277
+ * 이 축이 그 종류의 트윈에 **있는가** — 판정은 여기 하나다.
278
+ *
279
+ * 소비처가 `info.systems` 를 직접 비교하면 "선언하지 않으면 전부" 라는 기본값 규칙이 자리마다
280
+ * 복사되고, 한 곳이 빠지면 그 화면만 다른 답을 낸다(축 하나가 어떤 화면에서는 보이고 어떤
281
+ * 화면에서는 안 보인다). `axisSource` 와 같은 자리, 같은 이유다.
282
+ *
283
+ * **종류를 모르면 있다고 답한다** — 종류를 모르는 채로 축을 숨기면, 화면은 "해당 없음" 과
284
+ * "아직 모름" 을 같게 그리게 된다. 모르는 것을 근거로 감추지 않는다.
285
+ */
286
+ export const axisAppliesTo = (axis, system) => {
287
+ const declared = TWIN_AXES.find(a => a.axis === axis)?.systems;
288
+ if (!declared || !system)
289
+ return true;
290
+ /*
291
+ * **모르는 종류는 감추지 않는다.**
292
+ *
293
+ * 여기서 `includes` 로 곧장 답하면 오타 하나나 새 종류 하나가 축 열한 개를 **조용히 지운다**
294
+ * (개념 지도의 카드가 사라지고, 사용자는 그 트윈이 원래 그런 것이라고 읽는다). 종류를 아예
295
+ * 모르는 경우(`undefined`)에 있다고 답하는 것과 같은 이유다 — 모르는 것을 근거로 감추지 않는다.
296
+ *
297
+ * 등록된 종류가 그 축을 선언하지 않았을 때만 「해당 없음」이다.
298
+ */
299
+ if (!DOMAIN_SYSTEMS.includes(system))
300
+ return true;
301
+ return declared.includes(system);
302
+ };
303
+ /**
304
+ * 자리 타입 → **ISA-95 계층 단.** `hierarchyOf` 에 주입해 모델이 단을 적지 않아도 사슬을 걷게 한다.
305
+ *
306
+ * 종류를 알면 그 카탈로그만 본다. 모르면 **모든 종류에서 찾고, 답이 하나일 때만 답한다** — 두 종류가
307
+ * 같은 키를 다른 단으로 선언했다면 그중 하나를 고르는 것은 짐작이고, 짐작한 단으로 롤업하면 그
308
+ * 트윈의 집계가 조용히 틀린다. 그럴 때는 `undefined`(모른다)이고, 시험이 그 충돌 자체를 막는다.
309
+ */
310
+ export const levelOfLocationType = (typeKey, system) => {
311
+ const pick = (sys) => DOMAIN_CATALOG[sys]?.types.find(t => t.role === 'location' && t.key === typeKey)?.level;
312
+ if (system && DOMAIN_SYSTEMS.includes(system))
313
+ return pick(system);
314
+ const found = [...new Set(DOMAIN_SYSTEMS.map(pick).filter(Boolean))];
315
+ return found.length === 1 ? found[0] : undefined;
316
+ };
317
+ /** 그 종류의 트윈이 갖는 축들 — 개념 지도·적합성 표가 이 목록으로 그린다. */
318
+ export const axesOfSystem = (system) => TWIN_AXES.filter(a => axisAppliesTo(a.axis, system));
319
+ /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
320
+ export function capabilitiesForType(system, typeKey) {
321
+ return DOMAIN_CATALOG[system]?.types.find(t => t.key === typeKey)?.capabilities ?? [];
322
+ }