@operato/twin-kernel 0.2.2 → 0.3.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.
@@ -11,18 +11,189 @@ export type TaskStatus = 'created' | 'assigned' | 'in-progress' | 'completed';
11
11
  /**
12
12
  * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
13
13
  *
14
- * 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 노드 상태 채널이
14
+ * 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 자리 상태 채널이
15
15
  * 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
16
16
  *
17
17
  * 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
18
18
  * 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
19
19
  */
20
- export declare const NODE_SATURATION_NEAR = 0.9;
21
- export declare function nodeStatusOf(n: {
20
+ export declare const LOCATION_SATURATION_NEAR = 0.9;
21
+ export declare function locationStatusOf(n: {
22
22
  occupancy?: number;
23
23
  capacity?: number;
24
24
  }): 'available' | 'near-full' | 'full' | undefined;
25
- export interface NodeState {
25
+ /**
26
+ * 설비 계층 단계 — **ISA-95 표준 어휘.** 1차 출처: B2MML `B2MML-Common.xsd` /
27
+ * `EquipmentLevel1Type` 열거값 + `EquipmentLevelType` 주석("role based equipment hierarchy level
28
+ * as defined in ISA 95").
29
+ *
30
+ * 우리가 단의 이름을 발명하지 않는다. "라인" 은 `ProductionLine`, "존" 은 `StorageZone` 으로
31
+ * 표준이 이미 정해 뒀다. 발명하면 그 순간 방언이 되고, 연동 상대와 매핑 표가 필요해진다.
32
+ *
33
+ * **`Other` 는 탈출구다** — 표준도 열거값 밖을 인정한다(`OtherValue` 속성). 억지로 끼워 맞추는 대신
34
+ * `Other` 로 두고 현장의 낱말은 `type` 에 남긴다.
35
+ *
36
+ * `StorageZone`·`StorageUnit` 도 이 계층 안에 있다. 다만 **자리 자체는 다른 축**이다 —
37
+ * ISA-95 는 `OperationalLocation`("자원이 놓이거나 놓일 것으로 예상되는 논리적·물리적 장소",
38
+ * `B2MML-OperationalLocation.xsd`)을 별도 스키마로 두고, `Equipment` 가 자기 위치를 그것으로 가리킨다.
39
+ * 우리 `locations` 가 그 개념이다(2026-08-01 개명 — `plans/isa95-coverage.md` §3-1).
40
+ */
41
+ export declare const EQUIPMENT_LEVEL: readonly ["Enterprise", "Site", "Area", "ProcessCell", "Unit", "ProductionLine", "WorkCell", "ProductionUnit", "StorageZone", "StorageUnit", "WorkCenter", "WorkUnit", "EquipmentModule", "ControlModule", "Other"];
42
+ export type EquipmentLevel = (typeof EQUIPMENT_LEVEL)[number];
43
+ /** 표준 열거값인지 — 상류에서 들어온 값을 조용히 통과시키지 않고 확인하는 용도. */
44
+ export declare function isEquipmentLevel(v: unknown): v is EquipmentLevel;
45
+ /**
46
+ * 계층 질의 — **사슬을 걷는 규칙 한 벌.**
47
+ *
48
+ * 왜 커널이 내는가: 자리는 자리 밑에 들 수 있고(라인 ⊃ 스테이션), 설비는 자리에 붙박인다. 그래서
49
+ * "이 라인의 처리량" 같은 질문은 사슬을 걷어야 답이 나온다. 소비처(화면·집계·AI)가 각자 걷게 두면
50
+ * 규칙이 여러 벌이 되고, 한 홉만 보는 코드가 하나 남는 순간 **그 아래가 집계에서 조용히 빠진다.**
51
+ *
52
+ * `locationStatusOf` 와 같은 자리에 두는 이유도 같다 — 파생은 한 곳에서만 한다.
53
+ */
54
+ export interface Hierarchy {
55
+ /** 바로 아래 자리들. */
56
+ childrenOf(locationId: string): string[];
57
+ /** 상위 사슬 — 가까운 쪽부터. 마지막 원소는 자리가 아닐 수 있다(=구역). 자기 자신은 넣지 않는다. */
58
+ ancestorsOf(locationId: string): string[];
59
+ /**
60
+ * 사슬의 끝 — **자리가 아닌 최상위 소속(=구역).** 자리만으로 사슬이 끝나면(구역 미선언) `undefined`.
61
+ * 모름을 빈 문자열이나 자기 id 로 **꾸미지 않는다** — 구역 미상은 구역 0 이 아니다.
62
+ */
63
+ rollupOf(locationId: string): string | undefined;
64
+ /** 아래 전부(재귀). 자기 자신은 넣지 않는다. */
65
+ descendantsOf(locationId: string): string[];
66
+ /**
67
+ * 사슬을 올라가다 만나는 **그 단계의 가장 가까운 상위 자리** — "이 스테이션이 속한 라인" 은
68
+ * `ancestorOfLevel(st, 'ProductionLine')`. **이것이 표준 축이고 기본 질의다.**
69
+ *
70
+ * 없으면 `undefined` — 그 현장에 그 단계가 선언되지 않았다는 뜻이고, 아무 자리도 대신 내놓지 않는다.
71
+ */
72
+ ancestorOfLevel(locationId: string, level: EquipmentLevel): string | undefined;
73
+ /**
74
+ * 같은 질의를 **현장의 낱말**(`type`)로 — 단계를 아직 선언하지 않은 데이터를 위한 보조 경로다.
75
+ *
76
+ * 표준 단계가 있으면 `ancestorOfLevel` 을 쓴다. 이것은 마스터가 `level` 을 실어 주기 전까지의
77
+ * 임시 다리이고, 여기에 새 어휘를 쌓지 않는다(쌓으면 그게 방언이 된다).
78
+ */
79
+ ancestorOfType(locationId: string, type: string): string | undefined;
80
+ /**
81
+ * 이 자리에 **붙박인** 설비(`homeLocation` 기준) — 워크센터가 자리이자 설비인 경우의 이음.
82
+ * `deep` 이면 아래 자리들의 설비까지("이 라인의 설비").
83
+ *
84
+ * 지금 그 자리에 **와 있는** 설비(`location` 기준)와 다른 질문이다 — 지게차는 어디에나 와 있을 수
85
+ * 있지만 어디에도 붙박이지 않는다. 둘을 한 함수로 뭉개면 소속과 현재 위치가 섞인다.
86
+ */
87
+ equipmentOf(locationId: string, opts?: {
88
+ deep?: boolean;
89
+ }): string[];
90
+ }
91
+ /**
92
+ * 계층 색인을 만든다. **순환은 만들 때 잡고 던진다** — 렌더 도중에 터지는 대신 여기서 한 번에.
93
+ * 순환을 조용히 잘라 내면 롤업이 틀린 값을 내고, 그건 이 함수가 막으려는 바로 그 실패다.
94
+ */
95
+ export declare function hierarchyOf(s: {
96
+ locations: readonly {
97
+ id: string;
98
+ type?: string;
99
+ level?: EquipmentLevel;
100
+ parentId?: string;
101
+ }[];
102
+ equipment?: readonly {
103
+ id: string;
104
+ homeLocation?: string;
105
+ }[];
106
+ }): Hierarchy;
107
+ /**
108
+ * 자원 속성 — **ISA-95 가 세 자원에 똑같이 정의한 한 모양.**
109
+ *
110
+ * 1차 출처(B2MML v0701): `EquipmentPropertyType`(B2MML-Equipment.xsd) ·
111
+ * `PersonPropertyType`(B2MML-Personnel.xsd) · `PhysicalAssetPropertyType`(B2MML-PhysicalAsset.xsd).
112
+ * 세 타입의 요소가 동일하다 — `ID` · `Description?` · `Value(ValueType)` · `<X>PropertyChild`(재귀) ·
113
+ * `<X>ClassPropertyID`. 그래서 우리도 **자원마다 다른 모양을 만들지 않는다**(자원별 속성 타입 셋을
114
+ * 두면 그게 방언이 된다).
115
+ *
116
+ * `value`·`dataType`·`uom` 은 표준 `ValueType` 의 `ValueString`·`DataType`·`UnitOfMeasure` 다.
117
+ * 값을 **문자열로 싣는 것도 표준 그대로**다 — 단위와 데이터형이 값 옆에 붙어 있어야 소비처가
118
+ * "3" 이 초속 3m 인지 시속 3km 인지 알 수 있다(단위 없는 숫자는 거절한다는 규율의 근거).
119
+ *
120
+ * 왜 이제 계약에 넣는가: 지금까지 설비 속성(`speed`)이 **계약 밖으로 흘러** 마스터에서 호스트
121
+ * 추정기까지 `any` 로 전달됐다. 자원에 붙는 사실이 계약에 자리가 없으면 소비처마다 다르게 읽는다.
122
+ */
123
+ export interface ResourceProperty {
124
+ /** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다(`EQUIPMENT_PROPERTY`). */
125
+ id: string;
126
+ description?: string;
127
+ /** 표준 `Value.ValueString`. 값의 표기는 문자열이고, 뜻은 `dataType`·`uom` 이 정한다. */
128
+ value?: string;
129
+ /** 표준 `Value.DataType` — 예: `xs:double`. 미지정이면 소비처가 형을 짐작하지 않는다. */
130
+ dataType?: string;
131
+ /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`). **단위 없는 물리량은 쓰지 않는다.** */
132
+ uom?: string;
133
+ /** 하위 속성 — 표준 `<X>PropertyChild`(재귀). 구조화된 속성(예: 정격/실측 묶음)을 잃지 않기 위해. */
134
+ children?: ResourceProperty[];
135
+ /** 이 개체 속성이 구체화하는 **등급 속성** — 표준 `<X>ClassPropertyID`. */
136
+ classPropertyId?: string;
137
+ }
138
+ /**
139
+ * 자격·적격을 **검증한 시험 명세**들 — 표준 `TestSpecificationID`(`maxOccurs="unbounded"`).
140
+ *
141
+ * 1차 출처에서 **자원 타입 9개 중 9개**에 있다(`PersonType`·`PersonnelClassType`·`EquipmentType`·
142
+ * `EquipmentClassType`·`PhysicalAssetType`·`PhysicalAssetClassType`·`MaterialLotType`·
143
+ * `MaterialDefinitionType`·`MaterialClassType`). **`OperationalLocationType` 에만 없다** — 자리는
144
+ * 시험 대상이 아니기 때문이다. 그 비대칭이 표준의 판단이므로 우리도 자리에 넣지 않는다.
145
+ *
146
+ * 이 필드는 **참조만** 한다. 시험 명세 자체(`B2MML-OperationsTest.xsd`)와 시험 결과는 아직 모델에 없다
147
+ * (커버리지 ⬜). 그래서 이 값으로 **검증했다고 주장하지 않는다** — 상류가 "이 자격은 이 시험으로
148
+ * 검증됐다" 고 말해 줄 때 그 사실이 **들어올 자리**를 여는 것이 이 필드의 일이다.
149
+ * 자리가 없으면 사실이 들어오지 못한다.
150
+ */
151
+ export type TestSpecificationRefs = string[];
152
+ /**
153
+ * 자원 **등급 정의** — ISA-95 가 세 자원에 똑같이 정의한 한 모양.
154
+ *
155
+ * 1차 출처(B2MML v0701): `PersonnelClassType`(B2MML-Personnel.xsd) · `EquipmentClassType`(B2MML-Equipment.xsd)
156
+ * · `PhysicalAssetClassType`(B2MML-PhysicalAsset.xsd). 세 타입이 공통으로 갖는 것 —
157
+ * `ID` · `Description?` · `EffectiveStartDate?` · `EffectiveEndDate?` · **`<X>ClassBaseID`(복수)** ·
158
+ * `<X>ClassProperty`(복수) · `TestSpecificationID`(복수).
159
+ *
160
+ * **정의와 참조는 이름이 다르다.** 표준은 정의를 `<X>Class` 로, 참조를 `<X>ClassID` 로 부른다. 그래서
161
+ * 우리도 정의 목록은 `personnelClasses`, 개체의 소속은 `personnelClassIds` 다 — 한 낱말로 두면
162
+ * "이 필드가 정의인가 참조인가" 를 매번 물어야 한다.
163
+ *
164
+ * **상속이 왜 필요한가**: 요구가 "생산직 2명" 인데 현장에 있는 사람이 "용접 자격자" 라면, 상속이 없으면
165
+ * 그 사람은 요구를 만족하지 못한다. 표준이 `ClassBaseID` 를 복수로 둔 이유가 이것이고(다중 상속),
166
+ * 우리는 소속을 **상속을 타고 닫아** 판정한다(`classClosure`).
167
+ */
168
+ export interface ResourceClassDef {
169
+ id: string;
170
+ description?: string;
171
+ /** 상위 등급들 — 표준 `<X>ClassBaseID`(복수). 순환은 `classClosure` 가 끊는다. */
172
+ baseIds?: string[];
173
+ /** 등급 속성 — 표준 `<X>ClassProperty`. 개체 속성이 `classPropertyId` 로 이것을 구체화한다. */
174
+ properties?: ResourceProperty[];
175
+ /** 이 등급의 적격을 정하는 시험 명세들 — 표준 `TestSpecificationID`. */
176
+ testSpecificationIds?: TestSpecificationRefs;
177
+ /**
178
+ * 유효 기간 — 표준 `EffectiveStartDate` / `EffectiveEndDate`(ISO 시각).
179
+ *
180
+ * **표준은 날짜만 정하고 "밖이면 어떻게 되는가" 는 정하지 않는다.** 그 판단은 소비처 몫이므로
181
+ * 우리가 정한다: **기간 밖이면 그 등급으로 자격이 성립하지 않는다**(만료된 자격으로 배정되지 않는다).
182
+ * 우리가 정한 규칙이라는 사실을 여기 밝힌다.
183
+ */
184
+ effectiveStart?: ISOTime;
185
+ effectiveEnd?: ISOTime;
186
+ }
187
+ /**
188
+ * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
189
+ *
190
+ * 순환은 방문 집합으로 끊는다(잘못된 마스터가 무한 루프를 만들지 않게). 등급 정의가 없으면 소속
191
+ * 그대로만 본다 — 정의를 요구하지 않는다(정의를 싣지 않은 트윈이 그대로 돌아야 한다).
192
+ *
193
+ * `at` 를 주면 **유효 기간 밖의 등급은 제외**한다. 안 주면 기간을 보지 않는다(모르면 판단하지 않는다).
194
+ */
195
+ export declare function classClosure(directIds: readonly string[] | undefined, defs: readonly ResourceClassDef[] | undefined, at?: ISOTime): Set<string>;
196
+ export interface LocationState {
26
197
  id: string;
27
198
  type: string;
28
199
  /**
@@ -43,8 +214,32 @@ export interface NodeState {
43
214
  */
44
215
  parallelism?: number;
45
216
  occupancy: number;
46
- /** 포화도 파생 상태 — `nodeStatusOf` 가 낸다(두 구동이 같은 함수를 쓴다). 용량 미상이면 없다. */
217
+ /** 포화도 파생 상태 — `locationStatusOf` 가 낸다(두 구동이 같은 함수를 쓴다). 용량 미상이면 없다. */
47
218
  status?: string;
219
+ /**
220
+ * **ISA-95 설비 계층 단계** — `ProductionLine`·`StorageZone`·`WorkCenter` 등(`EQUIPMENT_LEVEL`).
221
+ *
222
+ * `type` 과 다른 축이다: `type` 은 현장의 낱말(`paint-booth`·`cut-station`)이고, 이것은 **표준이
223
+ * 정한 역할**이다. 둘을 겹쳐 두는 이유 — 연동 상대와는 표준 축으로 말하고, 화면에는 현장 낱말을
224
+ * 보여줘야 한다. 하나만 두면 한쪽을 잃는다.
225
+ *
226
+ * 미지정이면 그 자리의 표준 역할을 **아직 모른다**는 뜻이다. 짐작해 채우지 않는다.
227
+ */
228
+ level?: EquipmentLevel;
229
+ /**
230
+ * **상위 자리** — 주목 롤업·구역 매핑용(마스터 계층에서 유래).
231
+ *
232
+ * 가리키는 대상은 **두 가지 중 하나**이고, 둘은 조회로 구별된다(`locations` 에 그 id 가 있는지):
233
+ * - **다른 자리** — 중간 단(라인·셀·존). 계층은 여기서 깊어진다.
234
+ * - **자리가 아닌 것** — 공간의 구역(area). 사슬의 끝이다.
235
+ *
236
+ * 왜 깊이가 필요한가: 최종조립 16개 스테이션이 두 라인에 속하는데 중간 단이 없으면 전부 구역에
237
+ * 평평하게 붙고, **"라인 1의 처리량"을 물을 방법이 없다.** 현장은 라인 단위로 관리되는데 모델에
238
+ * 그 단이 없으면 관리 단위와 모델이 어긋난다.
239
+ *
240
+ * **사슬은 직접 걷지 말 것** — `ancestorsOf`·`rollupOf`·`descendantsOf` 를 쓴다. 한 홉만 보는 코드는
241
+ * 중간 단이 생기는 순간 그 아래 자리를 **집계에서 조용히 빠뜨린다**(정합성이 아니라 침묵이 문제다).
242
+ */
48
243
  parentId?: string;
49
244
  /**
50
245
  * 이 자리를 **어떻게 알게 됐는가** — `master`(원 시스템 마스터/저작이 말해 준 자리) ·
@@ -96,9 +291,9 @@ export interface ItemState {
96
291
  * 운영·키네마틱 모션 — State 이원 모델의 "연속" 절반.
97
292
  * 백엔드는 이동 시작 시 이 값(from/to/duration)만 방출하고, UI 는 progress 를
98
293
  * 로컬 프레임레이트로 보간한다(매 tick 통신 아님). 좌표는 커널 토폴로지 수준이 아니라
99
- * 보드 바인딩에서 노드→좌표로 해석. 상세: design/simulation/execution-model.md §4·§5
294
+ * 보드 바인딩에서 자리→좌표로 해석. 상세: design/simulation/execution-model.md §4·§5
100
295
  */
101
- export interface MoverMotion {
296
+ export interface EquipmentMotion {
102
297
  fromNode: string;
103
298
  toNode: string;
104
299
  startedAtSimMs: number;
@@ -124,13 +319,26 @@ export interface OeeMetrics {
124
319
  goodCount: number;
125
320
  scrapCount: number;
126
321
  }
127
- export interface MoverState {
322
+ export interface EquipmentState {
128
323
  id: string;
129
324
  kind: string;
325
+ /** **지금 어디에 있나.** 운반 작업이 끝나면 도착 자리로 옮겨진다(제자리 작업은 안 움직인다). */
130
326
  location?: string;
327
+ /**
328
+ * **어디에 속하나** — 붙박인 자리(마스터의 `homeLocationId`). `location` 과 다른 질문이다.
329
+ *
330
+ * 이 필드가 **고정 설비와 이동 설비를 가른다** — 새 타입 플래그 없이:
331
+ * - 도장기·용접로봇은 평생 그 자리에 있다 → `location === homeLocation` 가 항상 성립.
332
+ * - 지게차·호슬러는 돌아다닌다 → 둘이 갈린다. 그래도 소속은 `homeLocation` 하나로 안정적이다.
333
+ *
334
+ * 그래서 "이 라인의 설비 가동률" 은 `homeLocation` 로 묻고(소속), "지금 이 자리에 누가 와 있나" 는
335
+ * `location` 으로 묻는다. 예전에는 소속이 적재 시점에 `location` 초기값으로 소비되고 **버려졌다** —
336
+ * 그래서 이동 설비가 한 번 움직이면 원래 소속을 아무도 알 수 없었다.
337
+ */
338
+ homeLocation?: string;
131
339
  status: string;
132
340
  taskId?: string;
133
- motion?: MoverMotion;
341
+ motion?: EquipmentMotion;
134
342
  oee?: OeeMetrics;
135
343
  held?: boolean;
136
344
  /**
@@ -138,13 +346,20 @@ export interface MoverState {
138
346
  * 셋을 뭉개면 "왜 안 움직이나" 에 답할 수 없다(고쳐야 하나·풀어야 하나·기다려야 하나).
139
347
  */
140
348
  offShift?: boolean;
141
- /** 이 자원을 어떻게 알게 됐는가 — NodeState.origin 과 같은 뜻(성장 정책을 한 규칙으로 선언). */
349
+ /** 이 자원을 어떻게 알게 됐는가 — LocationState.origin 과 같은 뜻(성장 정책을 한 규칙으로 선언). */
142
350
  origin?: 'master' | 'observed';
351
+ /**
352
+ * 자원 속성 — 표준 `EquipmentProperty`. 속도(`EQUIPMENT_PROPERTY.speed`)처럼 **소비처가 읽는 사실**이
353
+ * 여기 실린다. 지금까지 계약 밖으로 흘러 호스트까지 `any` 로 전달됐다(§ResourceProperty).
354
+ */
355
+ properties?: ResourceProperty[];
356
+ /** 적격을 검증한 시험 명세들 — 표준 `Equipment.TestSpecificationID`(§TestSpecificationRefs). */
357
+ testSpecificationIds?: TestSpecificationRefs;
143
358
  }
144
359
  /**
145
360
  * 사람 — **ISA-95 `Person`.** 설비와 다른 자원 종류다.
146
361
  *
147
- * 왜 무버(설비)로 뭉개지 않는가: 사람은 고장 나지 않고(MTBF), 설비종합효율로 평가하지 않으며,
362
+ * 왜 설비(설비)로 뭉개지 않는가: 사람은 고장 나지 않고(MTBF), 설비종합효율로 평가하지 않으며,
148
363
  * **등급(자격)과 교대로 산다.** 같은 그릇에 담으면 설비의 어휘(고장·수리·OEE)가 사람에게 붙고
149
364
  * 사람의 어휘(등급·교대·투입 인원)가 설비에 붙는다 — 둘 다 거짓이 된다.
150
365
  *
@@ -153,13 +368,34 @@ export interface MoverState {
153
368
  */
154
369
  export interface PersonState {
155
370
  id: string;
156
- /** 소속 등급 — ISA-95 `PersonnelClassID`. 배정은 개인이 아니라 **등급으로 요구**된다. */
157
- personnelClass?: string;
371
+ /**
372
+ * 소속 등급들 — **ISA-95 `Person.PersonnelClassID`, `maxOccurs="unbounded"`**(B2MML-Personnel.xsd).
373
+ *
374
+ * **복수인 것이 표준이고, 그것이 표준의 자격 표현이다.** 한 사람이 용접 자격과 지게차 자격을 함께
375
+ * 갖는다 — 예전에는 이 필드가 문자열 하나여서 그 사람을 두 작업 중 하나에만 배정할 수 있었다
376
+ * (자격 하나를 고르면 나머지 자격이 사라지는 모델).
377
+ *
378
+ * 배정은 개인 지목이 아니라 **등급으로 요구**되고(`OpPersonnelSpecification` = ClassID + Quantity),
379
+ * 사람이 그 등급 중 하나를 **포함**하면 자격이 성립한다.
380
+ */
381
+ personnelClassIds?: string[];
158
382
  /** 'idle' | 'busy'. 고장(down)이 없다 — 사람은 그렇게 모델링하지 않는다. */
159
383
  status: string;
160
384
  taskId?: string;
161
- /** 교대 밖 — 자원(MoverState.offShift)과 같은 뜻. */
385
+ /** 교대 밖 — 자원(EquipmentState.offShift)과 같은 뜻. */
162
386
  offShift?: boolean;
387
+ /**
388
+ * 지금 어디에 있나 — **표준 `Person.OperationalLocation`**(B2MML-Personnel.xsd, `ResourceLocationType`).
389
+ *
390
+ * 표준은 사람에게 위치를 준다. 우리 모델에는 없어서 "이 라인에 몇 명 있나" 를 물을 수 없었다.
391
+ * 커널은 사람을 움직이지 않으므로(작업이 사람을 부른다) 이 값은 **마스터가 말해 주거나 관측으로
392
+ * 들어온다** — 그래서 사람 델타에도 자리가 있다(상태⊆이벤트).
393
+ */
394
+ location?: string;
395
+ /** 자원 속성 — 표준 `PersonProperty`. 자격증·숙련 등급 같은 사실이 여기 들어간다(§ResourceProperty). */
396
+ properties?: ResourceProperty[];
397
+ /** 자격을 검증한 시험 명세들 — 표준 `Person.TestSpecificationID`(§TestSpecificationRefs). */
398
+ testSpecificationIds?: TestSpecificationRefs;
163
399
  }
164
400
  /**
165
401
  * 물리 자산 — **ISA-95 `PhysicalAsset`, GS1 `GRAI`(반복사용 자산).**
@@ -174,7 +410,12 @@ export interface PersonState {
174
410
  export interface AssetState {
175
411
  id: string;
176
412
  /** 자산 등급 — pallet · rack · bin · trailer 등. 도메인 소유(코어는 강제하지 않는다). */
177
- assetClass?: string;
413
+ /**
414
+ * 속한 등급들 — **표준 `PhysicalAsset.PhysicalAssetClassID`, `maxOccurs="unbounded"`.**
415
+ * 인원과 같은 이유로 복수다(팔레트가 'euro-pallet' 이면서 'food-grade' 일 수 있다).
416
+ * 요구 쪽(`physicalAssetSpecification`)은 표준대로 등급 하나 + 수량이다.
417
+ */
418
+ assetClassIds?: string[];
178
419
  /** 지금 있는 자리. */
179
420
  location?: string;
180
421
  /** 'idle' | 'in-use'. 고장·OEE 로 평가하지 않는다(설비가 아니다). */
@@ -186,6 +427,10 @@ export interface AssetState {
186
427
  * 비어 있으면 빈 팔레트다(회수 대상이자 다음 출고의 재료).
187
428
  */
188
429
  carrying?: string;
430
+ /** 자원 속성 — 표준 `PhysicalAssetProperty`(§ResourceProperty). */
431
+ properties?: ResourceProperty[];
432
+ /** 적격을 검증한 시험 명세들 — 표준 `PhysicalAsset.TestSpecificationID`(§TestSpecificationRefs). */
433
+ testSpecificationIds?: TestSpecificationRefs;
189
434
  }
190
435
  export interface TaskState {
191
436
  id: string;
@@ -200,7 +445,7 @@ export interface TaskState {
200
445
  /**
201
446
  * 남은 시간·총 소요(ms) — 진행 중인 작업을 이어서 굴리는 데 필요(씨앗의 충실도).
202
447
  * `remainingMs` 는 **마지막 전이 시점의 값**이다. 델타는 매 tick 오지 않으므로(설계) 미러가 든 값은
203
- * 그때의 것이고, 지금 값은 `startedAtSimMs` 로 보간한다 — 모션(MoverMotion)과 같은 규율.
448
+ * 그때의 것이고, 지금 값은 `startedAtSimMs` 로 보간한다 — 모션(EquipmentMotion)과 같은 규율.
204
449
  */
205
450
  remainingMs?: number;
206
451
  durationMs?: number;
@@ -244,7 +489,7 @@ export interface Attention {
244
489
  severity: AttentionSeverity;
245
490
  state?: AttentionState;
246
491
  anchor: {
247
- nodeId?: string;
492
+ locationId?: string;
248
493
  moverId?: string;
249
494
  orderId?: string;
250
495
  };
@@ -252,7 +497,7 @@ export interface Attention {
252
497
  /**
253
498
  * 표현용 원시 파라미터(언어 중립). 커널은 사람이 읽는 문장을 만들지 않는다 — kind + params 만 방출하고
254
499
  * title/detail/rationale 는 표현계층(클라 i18next 템플릿)이 kind 로 키를 골라 params 를 보간해 렌더.
255
- * 예: bottleneck → { nodeId, occupancy, capacity, ratioPct, saturated }.
500
+ * 예: bottleneck → { locationId, occupancy, capacity, ratioPct, saturated }.
256
501
  */
257
502
  params?: Record<string, string | number>;
258
503
  recommendedActions?: RecommendedAction[];
@@ -275,9 +520,17 @@ export interface RecommendedAction {
275
520
  export interface StateSnapshot {
276
521
  revision: number;
277
522
  simClockMs: number;
278
- nodes: NodeState[];
523
+ locations: LocationState[];
279
524
  items: ItemState[];
280
- movers: MoverState[];
525
+ /**
526
+ * 설비 — **ISA-95 `Equipment`.** 고정 설비(도장기·용접로봇)와 이동 설비(지게차·호슬러)를 한 그릇에
527
+ * 든다. 둘의 상태 모델이 같기 때문이다(고장·가동·교대·계획정지·작업 점유). 갈리는 것은 하나뿐 —
528
+ * 소속(`homeLocation`)과 현재 위치(`location`)가 같은지.
529
+ *
530
+ * 예전 이름은 `movers` 였다(씬의 움직임 믹스인에서 물려받은 것). 커널에는 애니메이션이 없고 (vocabulary-guard: allow — 개명 경위 서술)
531
+ * 도장 부스는 아무것도 옮기지 않으므로 그 이름은 거짓이었다. 표준이 이미 정한 이름을 쓴다.
532
+ */
533
+ equipment: EquipmentState[];
281
534
  /** 사람 — 등급·교대·투입 상태. 인원을 선언하지 않은 트윈에서는 빈 배열. */
282
535
  persons: PersonState[];
283
536
  /** 물리 자산(반복사용) — 선언하지 않은 트윈에서는 빈 배열. */
@@ -352,7 +605,7 @@ export interface GeneratorSpec {
352
605
  kind: string;
353
606
  /**
354
607
  * 코어 라우팅 클래스: 공급(arrival, 물건이 들어옴 → onArrival) vs 수요(order, 요청이 들어옴 → onOrder).
355
- * 미지정 시 레거시 kind('outbound-order'→order, 그 외→arrival)로 추론(하위호환, 카탈로그 nodeTypes 폴백과 동형).
608
+ * 미지정 시 레거시 kind('outbound-order'→order, 그 외→arrival)로 추론(하위호환, 카탈로그 locationTypes 폴백과 동형).
356
609
  */
357
610
  stimulus?: 'arrival' | 'order';
358
611
  rate: RateSpec;
@@ -393,21 +646,23 @@ export declare const OP_EVENT: {
393
646
  /** 사람 상태 델타 — 배정·해제·교대 전이 시 방출. 미러가 인원 가용을 비추는 근거. */
394
647
  export interface PersonStatusDelta {
395
648
  personId: string;
396
- personnelClass?: string;
649
+ personnelClassIds?: string[];
397
650
  status: string;
398
651
  taskId?: string;
399
652
  offShift?: boolean;
653
+ /** 지금 어디에 있나 — 나가지 않으면 미러가 사람의 위치를 영영 모른다(상태⊆이벤트). */
654
+ location?: string;
400
655
  }
401
656
  /** 물리 자산 상태 델타 — 이동·투입·적재/하역 시 방출. 미러가 자산 가용을 비추는 근거. */
402
657
  export interface AssetStatusDelta {
403
658
  assetId: string;
404
- assetClass?: string;
659
+ assetClassIds?: string[];
405
660
  status: string;
406
661
  location?: string;
407
662
  taskId?: string;
408
663
  carrying?: string;
409
664
  }
410
- /** 품질 산출 델타 — recordOutput(양품/불량) 시 방출. goodCount/scrapCount 는 무버 누적값. */
665
+ /** 품질 산출 델타 — recordOutput(양품/불량) 시 방출. goodCount/scrapCount 는 설비 누적값. */
411
666
  export interface QualityDelta {
412
667
  moverId: string;
413
668
  good: boolean;
@@ -457,9 +712,11 @@ export interface EquipmentStatusDelta {
457
712
  kind: string;
458
713
  status: string;
459
714
  location?: string;
715
+ /** 붙박인 자리(`EquipmentState.homeLocation`). 나가지 않으면 미러가 소속을 영영 모른다 — 상태⊆이벤트. */
716
+ homeLocation?: string;
460
717
  /** 지금 붙어 있는 작업 — 사람·자산 델타와 같은 자리. 없으면 미러가 작업↔자원 연결을 모른다. */
461
718
  taskId?: string;
462
- motion?: MoverMotion;
719
+ motion?: EquipmentMotion;
463
720
  }
464
721
  /** 관측된 오더 라인(SKU 데맨드) — 실 시스템 오더는 품목 라인을 가짐. 이행 예측(남은 데맨드 재계획)에 필요. */
465
722
  export interface ObservedOrderLine {
@@ -495,9 +752,37 @@ export declare const CMD: {
495
752
  export type OperationalDelta = TaskStatusDelta | EquipmentStatusDelta | PersonStatusDelta | AssetStatusDelta | OrderStatusDelta;
496
753
  export type EventHandler = (e: CanonicalEnvelope) => void;
497
754
  export type Unsubscribe = () => void;
755
+ /**
756
+ * **저장된 보드를 읽는 단 하나의 입구.**
757
+ *
758
+ * `equipment` 로 개명하기 전에 저장된 보드는 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
759
+ * 저장물을 다시 쓰지 않고 **읽을 때 흡수**한다 — 마이그레이션은 되돌리기 어렵고, 읽기 호환은 값싸다.
760
+ *
761
+ * 규율 둘:
762
+ * - 이 함수를 **거치지 않고** `def.equipment` 를 직접 읽는 코드를 두지 않는다. 하나라도 남으면
763
+ * 그 경로에서만 옛 보드의 설비가 조용히 사라진다(빈 배열).
764
+ * - **쓸 때는 새 이름만** 쓴다. 두 이름으로 쓰기 시작하면 저장물에 두 벌이 영구히 섞인다.
765
+ *
766
+ * 제거 시점: 저장된 보드가 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
767
+ * 사라진다. 그때까지 남겨 두는 이유를 여기 적어 두는 것이 주석의 일이다.
768
+ */
769
+ /**
770
+ * **저장된 보드의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
771
+ *
772
+ * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 보드는 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
773
+ * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
774
+ * 옛 보드의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
775
+ */
776
+ export declare function readBoardLocations(def: BoardDef | (Record<string, unknown> & {
777
+ locations?: unknown;
778
+ nodes?: unknown;
779
+ })): BoardDef['locations'];
780
+ export declare function readBoardEquipment(def: BoardDef | Record<string, unknown>): BoardDef['equipment'];
781
+ /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
782
+ export declare function readBoardAssets(def: BoardDef | Record<string, unknown>): NonNullable<BoardDef['assets']>;
498
783
  export interface BoardDef {
499
- /** parallelism = 동시 처리 수(NodeState.parallelism 참조). capacity 는 저장 용량. */
500
- nodes: {
784
+ /** parallelism = 동시 처리 수(LocationState.parallelism 참조). capacity 는 저장 용량. */
785
+ locations: {
501
786
  id: string;
502
787
  type: string;
503
788
  capacity: number;
@@ -505,38 +790,55 @@ export interface BoardDef {
505
790
  parentId?: string;
506
791
  }[];
507
792
  /**
508
- * 무버(설비). mtbfMs/mttrMs 지정 시 확률적 고장 모델 참여(OEE Availability 손실). 미지정=고장 없음.
793
+ * 설비(설비). mtbfMs/mttrMs 지정 시 확률적 고장 모델 참여(OEE Availability 손실). 미지정=고장 없음.
509
794
  * `window` 지정 시 그 시간대에만 일한다(교대·가동시간) — 미지정이면 24시간 가용(기존 거동).
510
795
  */
511
- movers: {
796
+ equipment: {
512
797
  id: string;
513
798
  kind: string;
514
- homeNode: string;
799
+ homeLocation: string;
515
800
  mtbfMs?: number;
516
801
  mttrMs?: number;
517
802
  window?: {
518
803
  startHour: number;
519
804
  endHour: number;
520
805
  };
806
+ properties?: ResourceProperty[];
807
+ testSpecificationIds?: TestSpecificationRefs;
521
808
  }[];
522
809
  /**
523
- * 사람 — ISA-95 `Person`. `personnelClass` 로 등급을 밝히고, `window` 로 교대를 선언한다.
810
+ * 사람 — ISA-95 `Person`. `personnelClasses` 로 **속한 등급들**을 밝히고(복수가 표준), `window` 로
811
+ * 교대를 선언한다.
524
812
  * 선언하지 않으면 인원 제약이 없는 트윈이다(기존 거동).
525
813
  */
526
814
  persons?: {
527
815
  id: string;
528
- personnelClass?: string;
816
+ personnelClassIds?: string[];
529
817
  window?: {
530
818
  startHour: number;
531
819
  endHour: number;
532
820
  };
821
+ homeLocation?: string;
822
+ properties?: ResourceProperty[];
823
+ testSpecificationIds?: TestSpecificationRefs;
533
824
  }[];
534
825
  /** 물리 자산(반복사용) — ISA-95 `PhysicalAsset` / GS1 `GRAI`. 선언하지 않으면 자산 제약이 없다. */
535
826
  assets?: {
536
827
  id: string;
537
- assetClass?: string;
538
- homeNode?: string;
828
+ assetClassIds?: string[];
829
+ homeLocation?: string;
830
+ properties?: ResourceProperty[];
831
+ testSpecificationIds?: TestSpecificationRefs;
539
832
  }[];
833
+ /**
834
+ * **등급 정의** — 표준 `PersonnelClass` · `EquipmentClass` · `PhysicalAssetClass`.
835
+ *
836
+ * 선언하지 않아도 트윈은 돈다(소속 문자열 그대로 판정). 선언하면 **상속과 유효기간**이 살아난다 —
837
+ * "생산직 2명" 요구를 "용접 자격자" 가 만족하고, 만료된 자격은 배정되지 않는다.
838
+ */
839
+ personnelClasses?: ResourceClassDef[];
840
+ equipmentClasses?: ResourceClassDef[];
841
+ assetClasses?: ResourceClassDef[];
540
842
  }
541
843
  export interface TwinKernel {
542
844
  loadBoard(def: BoardDef): void;