@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,1248 @@
1
+ /*
2
+ * Face 1 — 3채널 계약 (walking skeleton 범위).
3
+ * 설계 SoT: operato-twin/design/integration/face1-contract.md
4
+ *
5
+ * ── 이 계약을 넓히려는 사람이 먼저 읽을 것 (2026-08-23) ──────────────────────
6
+ *
7
+ * 사실은 두 종류이고, 종류가 그 자리를 정한다.
8
+ *
9
+ * **파생** — 시각만으로 넘어가는 판정. 근무 밖 · 교대 · 능력 · 자리 상태 · 납기 대비 · 진척.
10
+ * · **저장하지 않는다.** 저장하면 장부가 둘이 되고, 시각이 지나도 옛 값이 남는다.
11
+ * · **입력으로 받지 않는다.** 원본이 단정하면 답이 둘이 되고 커널이 어느 쪽을 믿을지 정해야 한다.
12
+ * · 계산할 수 없으면 **답하지 않는다.** 모르는 값을 숫자로 만들면 화면이 그것을 사실로 그린다
13
+ * (`dueStatusOf` 가 그 규율을 먼저 적었다: 납기가 없으면 「늦지 않았다」가 아니라 「판단할 수 없다」).
14
+ * · 파생이 **불가능할 때만** 원본의 단정을 쓴다. 그 순서가 선언돼 있어야 한다(`offShift`·`progress`).
15
+ *
16
+ * **누적** — 열린 구간에 쌓이는 값. 가동·준비·고장 시간 · 양품/불량 수 · 조건이 성립한 시각.
17
+ * · **다시 계산할 수 없다.** 원천은 「이번 창에서 지금까지 얼마」를 모른다.
18
+ * · 그래서 **재기동을 넘어 이어받는다.** 잃으면 오류 없이 값이 작아진다 — 가장 비싼 종류의 침묵이다.
19
+ *
20
+ * 그리고 **자리를 늘리는 조건은 하나다: 전이로 표현할 수 없는 사실.**
21
+ *
22
+ * 「자리가 없다」와 「담을 수 없다」는 다르다. 전자는 대개 이미 있는 축을 못 찾은 것이다 — 작업의
23
+ * 실제 착수·완료·대기는 **전이의 `eventTime`** 이 말하고(`task-fold`), 대기는 그것으로 계산된다
24
+ * (`kpi-fold`: `waitMs = startedMs − createdMs`). 원본이 전이를 놓쳐 보내면 **놓친 전이를 그 시각으로
25
+ * 함께 내는 것**이 답이고, 속성을 새로 여는 것이 아니다.
26
+ *
27
+ * 그러므로 원본을 붙이는 사람이 물어야 하는 것은 「이 컬럼을 실을 자리가 있나」가 아니라
28
+ * **「원본이 말하는 이 사실을 커널의 어휘로 어떻게 옮기나」**다. 컬럼 단위로 물으면 원본이 열이면
29
+ * 축도 열이 된다 — 2026-08-23 에 축 다섯을 열었다가 그 이유로 되돌렸다.
30
+ *
31
+ * 열지 않기로 한 것과 그 조건은 `design/04-decisions.md` ADR-0039 에 있다. 넓히기 전에 그 표를 본다.
32
+ * 지키는 하네스: `derived-not-input` · `actuals-come-from-transitions` · `accumulators-survive-restart` ·
33
+ * `purpose-conformance`.
34
+ */
35
+ /** 오더가 **종결 상태**로 인정되는 낱말 — 이 밖은 진행 중이다(§`isOrderTerminal`). */
36
+ export const ORDER_TERMINAL_STATUS = ['completed', 'cancelled'];
37
+ /**
38
+ * 이 오더는 **끝났나** — 판정을 한 곳에 둔다(`locationStatusOf`·`dueStatusOf` 와 같은 규율).
39
+ *
40
+ * ── 왜 함수여야 하나 (2026-08-23) ───────────────────────────────────────────
41
+ * 이 판정이 없던 동안 호스트의 성과 폴드가 **낱말을 동의어 목록으로 추론했다**
42
+ * (`completed`·`fulfilled`·`done`·`finished`). 그 방식이 낸 대가가 둘이다.
43
+ *
44
+ * ① **원본의 낱말이 목록에 없으면 완료가 조용히 0 이 된다.** 실제로 그랬다 — 첫 실 시스템의
45
+ * `FINISHED` 가 빠져 있었고, 사람이 화면을 보고 의심해서야 드러났다. 그리고 그때 목록에 낱말을
46
+ * 더한 것은 진짜 빈 자리(커넥터가 자기 상태 표를 안 쓰고 있었다)를 가린 땜빵이었다.
47
+ * ② **시뮬 트윈에서는 언제나 0 이었다.** 시뮬은 오더를 이행하면서 `fulfilled` 만 올리고 `status` 는
48
+ * 바꾸지 않는다(그런 대입이 코드에 없다). 그래서 낱말로 묻는 판정은 시뮬의 완료를 **한 건도**
49
+ * 세지 못했다. 낱말이 틀린 것이 아니라 **묻는 축이 틀렸다.**
50
+ *
51
+ * ── 표준이 그 축을 이미 갈라 두었다 ─────────────────────────────────────────
52
+ * `orders` 는 `OperationsRequest` 쪽이고 그 상태는 `RequestState` 다 — **표준도 그 값을 열거하지
53
+ * 않는다.** 그래서 이 축이 열려 있는 것은 방언이 아니라 표준 정합이다. 대신 표준은 「됐나」를 **실적**
54
+ * 에서 읽는다(`JobResponse`·`SegmentResponse`, 그리고 요구는 `SegmentRequirement.Quantity`).
55
+ *
56
+ * 그러므로 이 판정의 1차 근거는 **양**이다: 요구한 만큼 이행됐으면 끝난 것이고, 그 사실은 어느 원본의
57
+ * 어느 낱말과도 무관하다. 낱말은 **양으로 말할 수 없는 종결**을 위해 함께 본다 — 취소, 그리고 요구량을
58
+ * 채우지 못한 채 닫힌 오더.
59
+ *
60
+ * ── 도메인이 상태에 다른 것을 적는다 ────────────────────────────────────────
61
+ * MES 커널은 이 자리에 **진행 단계**를 적는다(`op-<공정키>`, `blocked-seed-incomplete`). 그 값들은
62
+ * 종결이 아니므로 이 판정이 옳게 「아니다」로 답한다. 다만 진행 단계를 상태 문자열에 적는 것 자체가
63
+ * 표준의 모양은 아니다(그것은 `SegmentResponse` 의 일이다) — 그 빚은 ADR-0039 에 조건과 함께 적었다.
64
+ *
65
+ * 모르면 `false` 다 — 「끝나지 않았다」가 아니라 **「끝났다고 말할 근거가 없다」**이고, 성과는 근거가
66
+ * 있는 것만 센다(없는 완료를 세면 처리량이 조용히 부풀려진다).
67
+ */
68
+ export function isOrderTerminal(o) {
69
+ const status = String(o?.status ?? '').trim().toLowerCase();
70
+ if (ORDER_TERMINAL_STATUS.includes(status))
71
+ return true;
72
+ /* 양으로 판정 — 요구량이 없으면 판정하지 않는다(0 을 「다 됐다」로 읽지 않는다). */
73
+ const requested = o?.requested;
74
+ const fulfilled = o?.fulfilled;
75
+ if (typeof requested !== 'number' || !(requested > 0))
76
+ return false;
77
+ return typeof fulfilled === 'number' && fulfilled >= requested;
78
+ }
79
+ /**
80
+ * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
81
+ *
82
+ * 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 자리 상태 채널이
83
+ * 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
84
+ *
85
+ * 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
86
+ * 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
87
+ */
88
+ export const LOCATION_SATURATION_NEAR = 0.9;
89
+ export function locationStatusOf(n) {
90
+ const cap = n.capacity;
91
+ if (!(typeof cap === 'number' && cap > 0))
92
+ return undefined;
93
+ const r = (n.occupancy ?? 0) / cap;
94
+ return r >= 1 ? 'full' : r >= LOCATION_SATURATION_NEAR ? 'near-full' : 'available';
95
+ }
96
+ /**
97
+ * 설비 계층 단계 — **ISA-95 표준 어휘.** 1차 출처: B2MML `B2MML-Common.xsd` /
98
+ * `EquipmentLevel1Type` 열거값 + `EquipmentLevelType` 주석("role based equipment hierarchy level
99
+ * as defined in ISA 95").
100
+ *
101
+ * 우리가 단의 이름을 발명하지 않는다. "라인" 은 `ProductionLine`, "존" 은 `StorageZone` 으로
102
+ * 표준이 이미 정해 뒀다. 발명하면 그 순간 방언이 되고, 연동 상대와 매핑 표가 필요해진다.
103
+ *
104
+ * **`Other` 는 탈출구다** — 표준도 열거값 밖을 인정한다(`OtherValue` 속성). 억지로 끼워 맞추는 대신
105
+ * `Other` 로 두고 현장의 낱말은 `type` 에 남긴다.
106
+ *
107
+ * `StorageZone`·`StorageUnit` 도 이 계층 안에 있다. 다만 **자리 자체는 다른 축**이다 —
108
+ * ISA-95 는 `OperationalLocation`("자원이 놓이거나 놓일 것으로 예상되는 논리적·물리적 장소",
109
+ * `B2MML-OperationalLocation.xsd`)을 별도 스키마로 두고, `Equipment` 가 자기 위치를 그것으로 가리킨다.
110
+ * 우리 `locations` 가 그 개념이다(2026-08-01 개명 — `plans/isa95-coverage.md` §3-1).
111
+ */
112
+ export const EQUIPMENT_LEVEL = [
113
+ 'Enterprise',
114
+ 'Site',
115
+ 'Area',
116
+ 'ProcessCell',
117
+ 'Unit',
118
+ 'ProductionLine',
119
+ 'WorkCell',
120
+ 'ProductionUnit',
121
+ 'StorageZone',
122
+ 'StorageUnit',
123
+ 'WorkCenter',
124
+ 'WorkUnit',
125
+ 'EquipmentModule',
126
+ 'ControlModule',
127
+ 'Other'
128
+ ];
129
+ /** 표준 열거값인지 — 상류에서 들어온 값을 조용히 통과시키지 않고 확인하는 용도. */
130
+ export function isEquipmentLevel(v) {
131
+ return typeof v === 'string' && EQUIPMENT_LEVEL.includes(v);
132
+ }
133
+ /**
134
+ * 계층 색인을 만든다. **순환은 만들 때 잡아 오류를 낸다** — 렌더 도중에 터지는 대신 여기서 한 번에.
135
+ * 순환을 조용히 잘라 내면 롤업이 틀린 값을 내고, 그건 이 함수가 막으려는 바로 그 실패다.
136
+ */
137
+ export function hierarchyOf(s,
138
+ /**
139
+ * 자리가 단(`level`)을 적지 않았을 때 **타입으로 알아내는 해석기** — 카탈로그가 주입한다.
140
+ *
141
+ * 왜 주입인가: 단은 타입의 성질이라 카탈로그가 선언하는데(`TwinTypeInfo.level`), 이 파일은
142
+ * 카탈로그보다 아래에 있다(카탈로그가 이 파일을 읽는다). 여기서 카탈로그를 부르면 순환이 된다.
143
+ *
144
+ * 왜 필요한가: 실측(2026-08-14) 로케이션 82개 중 `level` 을 적은 것이 **0개**였다. 모델이 적어 줄
145
+ * 때까지 기다리면 `ancestorOfLevel` 은 계속 `undefined` 를 답한다 — 선언은 있고 답은 없는 상태다.
146
+ * 자리가 적었으면 그것이 권위이고(현장이 우리보다 자기 계층을 잘 안다), 없으면 타입이 답한다.
147
+ */
148
+ levelOfType) {
149
+ const push = (m, k, v) => {
150
+ const cur = m.get(k);
151
+ if (cur)
152
+ cur.push(v);
153
+ else
154
+ m.set(k, [v]);
155
+ };
156
+ const parent = new Map();
157
+ const kids = new Map();
158
+ const known = new Set(s.locations.map(n => n.id));
159
+ for (const n of s.locations) {
160
+ if (!n.parentId || n.parentId === n.id)
161
+ continue; // 자기 부모는 선언 오류 — 사슬에 넣지 않는다
162
+ parent.set(n.id, n.parentId);
163
+ if (known.has(n.parentId))
164
+ push(kids, n.parentId, n.id);
165
+ }
166
+ /* 순환 검출 — 자리마다 사슬을 끝까지 밀어 본다. 방문한 자리를 다시 만나면 그 경로를 그대로 알린다. */
167
+ for (const start of known) {
168
+ const seen = [start];
169
+ for (let at = parent.get(start); at !== undefined; at = parent.get(at)) {
170
+ if (seen.includes(at))
171
+ throw new Error(`location hierarchy has a cycle: ${[...seen, at].join(' → ')}`);
172
+ seen.push(at);
173
+ if (!known.has(at))
174
+ break; // 구역에 닿았다 — 사슬 끝
175
+ }
176
+ }
177
+ const homes = new Map();
178
+ for (const m of s.equipment ?? [])
179
+ if (m.homeLocation)
180
+ push(homes, m.homeLocation, m.id);
181
+ const ancestorsOf = (id) => {
182
+ const out = [];
183
+ for (let at = parent.get(id); at !== undefined; at = parent.get(at)) {
184
+ out.push(at);
185
+ if (!known.has(at))
186
+ break;
187
+ }
188
+ return out;
189
+ };
190
+ const descendantsOf = (id) => {
191
+ const out = [];
192
+ const stack = [...(kids.get(id) ?? [])];
193
+ while (stack.length) {
194
+ const cur = stack.pop();
195
+ out.push(cur);
196
+ stack.push(...(kids.get(cur) ?? []));
197
+ }
198
+ return out;
199
+ };
200
+ const typeOf = new Map(s.locations.map(n => [n.id, n.type]));
201
+ /* 자리의 선언이 먼저, 없으면 타입 — 어느 쪽으로 알았는지는 소비처가 물을 일이 없다(같은 사실이다). */
202
+ const levelOf = new Map(s.locations.map(n => [n.id, n.level ?? (n.type ? levelOfType?.(n.type) : undefined)]));
203
+ return {
204
+ childrenOf: id => [...(kids.get(id) ?? [])],
205
+ ancestorsOf,
206
+ descendantsOf,
207
+ rollupOf: id => ancestorsOf(id).find(a => !known.has(a)),
208
+ ancestorOfLevel: (id, level) => ancestorsOf(id).find(a => known.has(a) && levelOf.get(a) === level),
209
+ ancestorOfType: (id, type) => ancestorsOf(id).find(a => known.has(a) && typeOf.get(a) === type),
210
+ equipmentOf: (id, opts) => {
211
+ const scope = opts?.deep ? [id, ...descendantsOf(id)] : [id];
212
+ return scope.flatMap(n => homes.get(n) ?? []);
213
+ }
214
+ };
215
+ }
216
+ /**
217
+ * `OP_EVENT.test` 의 페이로드 — **결과가 대상을 가리킨다.**
218
+ *
219
+ * 표준(`TestResultType`)의 방향 그대로다. 상태에서는 개체 안에 계산해 두지만(소비처가 대상별로 묻는다)
220
+ * 사건에서는 가리킨다 — 그래야 대상이 무엇이든(로트·설비·사람·자리) 한 채널로 들어온다.
221
+ */
222
+ /**
223
+ * 부적합 처분의 **결정** — 좁힌 목록이다.
224
+ *
225
+ * 표준(ISA-95 Part 4 · 품질 관리 관행)이 목록을 공표하지 않으므로 우리가 정했다. 넓힐 때는 이 주석과
226
+ * 함께 움직인다. 열린 문자열로 두지 않는 이유는, 처분이 **자원에 효과를 주기** 때문이다 — 커널이 모르는
227
+ * 결정을 받으면 무엇을 해야 할지 알 수 없고, 그때 조용히 아무것도 하지 않는 것이 가장 나쁘다.
228
+ */
229
+ export const DISPOSITION_DECISION = ['rework', 'use-as-is', 'scrap', 'return-to-supplier', 'hold'];
230
+ /**
231
+ * 이 시험 결과가 **이 시각에 유효한 합격인가.**
232
+ *
233
+ * 판정을 한 곳에 둔다 — 자리마다 `result === 'pass'` 와 날짜 비교를 다시 적으면 한 곳이 빠지고,
234
+ * 빠진 쪽은 만료된 자격을 통과시킨다(조용한 결함).
235
+ */
236
+ export function testPassedAt(r, at) {
237
+ if (r.result !== 'pass')
238
+ return false;
239
+ if (!r.expiresAt || !at)
240
+ return r.result === 'pass';
241
+ return parsedMs(at) <= parsedMs(r.expiresAt);
242
+ }
243
+ /**
244
+ * 이 개체가 **요구된 시험들을 만족하나** — 등급이 요구하고 개체가 기록을 든다.
245
+ *
246
+ * `required` 가 비면 요구가 없으므로 참이다. 요구된 시험에 **결과가 없으면 참**이다(위 머리말의 규율:
247
+ * 없는 것으로 막지 않는다). 결과가 있으면 그것이 유효한 합격이어야 한다.
248
+ */
249
+ export function meetsTests(required, results, at) {
250
+ if (!required.length)
251
+ return true;
252
+ const bySpec = new Map((results ?? []).map(r => [r.specId, r]));
253
+ for (const specId of required) {
254
+ const r = bySpec.get(specId);
255
+ if (!r)
256
+ continue; // 결과가 선언되지 않았다 — 제약이 아니다
257
+ if (!testPassedAt(r, at))
258
+ return false;
259
+ }
260
+ return true;
261
+ }
262
+ /**
263
+ * **이 기준이 아무 한계도 말하지 않나** — 선언만 있고 내용이 없는 것을 가려낸다.
264
+ *
265
+ * 표현식(사람이 읽는 것)도 없고 숫자 한계(커널이 판정하는 것)도 없으면, 그 기준은 「기준이 선언됐다」는
266
+ * 착각만 만든다. 화면이 「관리점 셋이 걸려 있다」고 말하는데 그중 하나가 아무것도 재지 않는 것은
267
+ * 이 저장소가 거절하는 모양이다.
268
+ */
269
+ export function criterionSaysNothing(c) {
270
+ if (c?.expression?.trim())
271
+ return false;
272
+ const l = c?.limit;
273
+ return !(typeof l?.minimum === 'number' || typeof l?.maximum === 'number');
274
+ }
275
+ /**
276
+ * **이 값이 한계를 벗어났나** — 판정할 수 있을 때만 답한다.
277
+ *
278
+ * ── 세 갈래를 가린다 ────────────────────────────────────────────────────────
279
+ * `true` 벗어났다
280
+ * `false` 한계 안이다
281
+ * `undefined` **판정할 수 없다** — 숫자 한계가 없거나, 값이 수가 아니거나, 단위가 어긋난다
282
+ *
283
+ * `undefined` 를 `false` 로 뭉개지 않는 것이 이 함수의 요점이다. 「한계 안이다」와 「판정 못 했다」를
284
+ * 같은 값으로 답하면 화면이 판정하지 못한 것을 **적합으로** 보여 준다 — 규제 기록에서 그것은 사고다.
285
+ *
286
+ * ── 표현식은 읽지 않는다 ────────────────────────────────────────────────────
287
+ * `expression` 이 있어도 파싱하지 않는다(§`TestSpecificationCriterion.expression`). 숫자 한계가
288
+ * 없으면 사람이 읽을 문장은 있어도 커널은 「모른다」고 답한다.
289
+ *
290
+ * ── 단위가 어긋나면 판정하지 않는다 ─────────────────────────────────────────
291
+ * 섭씨 한계에 화씨 관측을 견주면 **오류 없이 틀린다.** 둘 다 단위를 말하지 않으면 같은 척도로 본다 —
292
+ * 같은 선언에서 온 값들이고, 그때 판정을 거부하면 단위를 적지 않는 원본에서 이 축이 영원히 침묵한다.
293
+ */
294
+ export function outsideLimit(criterion, observed) {
295
+ const l = criterion?.limit;
296
+ if (!l)
297
+ return undefined;
298
+ const hasMin = typeof l.minimum === 'number';
299
+ const hasMax = typeof l.maximum === 'number';
300
+ if (!hasMin && !hasMax)
301
+ return undefined;
302
+ const raw = observed?.value;
303
+ if (raw === undefined || raw === null || String(raw).trim() === '')
304
+ return undefined;
305
+ const n = Number(raw);
306
+ if (!Number.isFinite(n))
307
+ return undefined;
308
+ /* 단위가 **둘 다 있고 다르면** 판정하지 않는다. 하나만 있거나 둘 다 없으면 같은 척도로 본다. */
309
+ const lu = l.uom?.trim();
310
+ const ou = observed?.uom?.trim();
311
+ if (lu && ou && lu !== ou)
312
+ return undefined;
313
+ if (hasMin && n < l.minimum)
314
+ return true;
315
+ if (hasMax && n > l.maximum)
316
+ return true;
317
+ return false;
318
+ }
319
+ /**
320
+ * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
321
+ *
322
+ * ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
323
+ * 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
324
+ * **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
325
+ * 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
326
+ *
327
+ * unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
328
+ * unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
329
+ *
330
+ * 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
331
+ * 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
332
+ */
333
+ export function testEvidenceGaps(spec, result) {
334
+ const criteria = spec?.criteria ?? [];
335
+ const measurements = result?.propertyMeasurements ?? [];
336
+ /* 기준이 재는 속성들 / 실제로 잰 속성들 — 속성 id 를 말하지 않은 것은 짝을 맞출 수 없으므로 뺀다. */
337
+ const wanted = new Set(criteria.map(c => c.evaluatedPropertyId).filter((k) => !!k));
338
+ const got = new Set(measurements.map(m => m.testableObjectPropertyId).filter((k) => !!k));
339
+ return {
340
+ unmeasured: criteria
341
+ .filter(c => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId))
342
+ .map(c => c.id),
343
+ unmatched: measurements
344
+ .map(m => m.testableObjectPropertyId)
345
+ .filter((k) => !!k && !wanted.has(k))
346
+ };
347
+ }
348
+ /**
349
+ * **선언된 기준으로 판정한다** — 원천이 판정하지 않았을 때 커널이 답을 낼 수 있는가.
350
+ *
351
+ * ── 왜 이 함수가 있나 (당위) ────────────────────────────────────────────────
352
+ * 트윈은 **아무도 판정하지 않을 때 판정해야 한다.** 값이 있고 기준이 있는데 판정이 없으면, 화면은
353
+ * 아무 말도 하지 않고 그것은 「전부 이상 없음」과 **화면상 똑같이 보인다.** 그 침묵이 이 함수가
354
+ * 없앤 것이다.
355
+ *
356
+ * ── 세 갈래로 답한다 (이 함수의 전부) ───────────────────────────────────────
357
+ *
358
+ * 'fail' 기준 하나라도 **분명히** 벗어났다
359
+ * 'pass' **말하는 기준 전부**를 판정했고 전부 안에 있다
360
+ * undefined 판정할 수 없다 — 하나라도 판정 못 한 기준이 있거나, 판정할 기준이 아예 없다
361
+ *
362
+ * `'pass'` 가 「말하는 기준 **전부**」를 요구하는 것이 이 함수의 핵심이다. 하나라도 판정하지 못했는데
363
+ * 합격이라 하면 **확인되지 않은 합격**이 되고, 규제 기록에서 그것은 결함이 아니라 사고다. 그래서
364
+ * 모르는 것이 하나라도 있으면 아무 말도 하지 않는다.
365
+ *
366
+ * 아무 한계도 말하지 않는 기준(`criterionSaysNothing`)은 **셈에서 뺀다** — 자유 문장만 적힌 기준
367
+ * 때문에 판정 가능한 것까지 침묵하면, 이 축이 그 현장에서 영원히 죽는다(고치려던 것의 반대 방향).
368
+ * 그 기준이 비어 있다는 사실은 `testEvidenceGaps` 와 `criterionSaysNothing` 이 따로 낸다 —
369
+ * **설정의 흠과 운영의 사실을 한 답에 섞지 않는다.**
370
+ *
371
+ * 커널은 `Expression` 을 **읽지 않는다**(문법이 정의되지 않은 자유 문자열). 판정은 숫자 한계로만 한다.
372
+ */
373
+ export function judgeAgainstSpec(spec, result) {
374
+ /* 판정할 기준만 남긴다 — 말하지 않는 기준은 이 답에 관여하지 않는다. */
375
+ const criteria = (spec?.criteria ?? []).filter(c => !criterionSaysNothing(c));
376
+ if (!criteria.length)
377
+ return undefined;
378
+ const measurements = result?.propertyMeasurements ?? [];
379
+ let allJudged = true;
380
+ for (const c of criteria) {
381
+ /* 그 기준이 재는 속성의 측정값 — 속성을 말하지 않은 기준은 짝을 맞출 수 없다. */
382
+ const m = c.evaluatedPropertyId
383
+ ? measurements.find(x => x.testableObjectPropertyId === c.evaluatedPropertyId)
384
+ : undefined;
385
+ const out = outsideLimit(c, m);
386
+ if (out === true)
387
+ return 'fail';
388
+ if (out === undefined)
389
+ allJudged = false;
390
+ }
391
+ return allJudged ? 'pass' : undefined;
392
+ }
393
+ export function effectivityAt(p, at, opts) {
394
+ if (!p || !at)
395
+ return undefined;
396
+ const atMs = parsedMs(at);
397
+ if (!Number.isFinite(atMs))
398
+ return undefined;
399
+ const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
400
+ if (Number.isFinite(from) && atMs < from)
401
+ return 'not-yet';
402
+ const to = p.effectiveEnd ? parsedMs(p.effectiveEnd) : NaN;
403
+ if (Number.isFinite(to) && (opts?.end === 'exclusive' ? atMs >= to : atMs > to))
404
+ return 'expired';
405
+ return undefined;
406
+ }
407
+ /*
408
+ * ── 같은 시각 문자열을 몇천 번 다시 파싱하지 않는다 (2026-08-17 프로파일) ───
409
+ *
410
+ * 커널 틱의 비용을 재 보니 `Date.parse` 를 부르는 이 판정들이 **틱 시간의 5분의 1** 이었다. 한 틱
411
+ * 안에서 오가는 시각 문자열은 사실 몇 개뿐이다: 그 틱의 "지금" 하나와, 마스터에 적힌 유효기간들
412
+ * (부팅 뒤 바뀌지 않는다). 같은 문자열이 계속 다시 파싱되고 있었다.
413
+ *
414
+ * 값이 아니라 **문자열 자체**를 열쇠로 기억한다 — 같은 문자열은 언제 물어도 같은 밀리초다(ISO 시각은
415
+ * 절대 시각이다). 그래서 이 기억은 결과를 바꾸지 않는다: 캐시가 없을 때와 정확히 같은 수를 낸다.
416
+ *
417
+ * 크기를 묶는다. 시각 문자열은 트윈이 굴러가는 동안 계속 새로 생기므로(틱마다 새 "지금"), 묶지 않으면
418
+ * 이 표가 곧 누수다. 실제로 필요한 것은 최근 몇 개뿐이다.
419
+ */
420
+ const PARSED_MAX = 64;
421
+ const parsedCache = new Map();
422
+ function parsedMs(at) {
423
+ const hit = parsedCache.get(at);
424
+ if (hit !== undefined)
425
+ return hit;
426
+ const ms = Date.parse(at);
427
+ if (parsedCache.size >= PARSED_MAX)
428
+ parsedCache.clear();
429
+ parsedCache.set(at, ms);
430
+ return ms;
431
+ }
432
+ /**
433
+ * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
434
+ *
435
+ * 순환은 방문 집합으로 끊는다(잘못된 마스터가 무한 루프를 만들지 않게). 등급 정의가 없으면 소속
436
+ * 그대로만 본다 — 정의를 요구하지 않는다(정의를 싣지 않은 트윈이 그대로 동작해야 한다).
437
+ *
438
+ * `at` 를 주면 **유효 기간 밖의 등급은 제외**한다. 안 주면 기간을 보지 않는다(모르면 판단하지 않는다).
439
+ */
440
+ /*
441
+ * ── 등급 정의 색인은 한 번만 만든다 (2026-08-17 프로파일) ───────────────────
442
+ *
443
+ * `classClosure` 는 부를 때마다 정의 목록으로 `Map` 을 새로 지었다. 그런데 등급 정의는 모델을 실을 때
444
+ * 정해지고 그 뒤 바뀌지 않는 목록이다 — 능력 판정마다 같은 표를 다시 짓느라 **틱 시간의 7분의 1** 을
445
+ * 썼다(프로파일 `classClosure` 14%).
446
+ *
447
+ * 배열 **자체**를 열쇠로 기억한다(`WeakMap`). 목록이 바뀌면 그것은 다른 배열이므로 새 표가 만들어지고,
448
+ * 목록이 사라지면 표도 함께 사라진다 — 무효화를 따로 관리할 것이 없다.
449
+ */
450
+ const classIndexCache = new WeakMap();
451
+ function classIndex(defs) {
452
+ if (!defs)
453
+ return EMPTY_CLASS_INDEX;
454
+ const hit = classIndexCache.get(defs);
455
+ if (hit)
456
+ return hit;
457
+ const built = new Map(defs.map(d => [d.id, d]));
458
+ classIndexCache.set(defs, built);
459
+ return built;
460
+ }
461
+ const EMPTY_CLASS_INDEX = new Map();
462
+ export function classClosure(directIds, defs, at) {
463
+ const byId = classIndex(defs);
464
+ const inWindow = (d) => !d || effectivityAt(d, at) === undefined;
465
+ const out = new Set();
466
+ const stack = [...(directIds ?? [])];
467
+ while (stack.length) {
468
+ const id = stack.pop();
469
+ if (out.has(id))
470
+ continue;
471
+ const def = byId.get(id);
472
+ if (!inWindow(def))
473
+ continue; // 만료된 등급은 자기도, 그 상위도 타지 않는다
474
+ out.add(id);
475
+ for (const b of def?.baseIds ?? [])
476
+ if (!out.has(b))
477
+ stack.push(b);
478
+ }
479
+ return out;
480
+ }
481
+ /**
482
+ * 우선순위 — **ISA-95 `Priority`**(`JobOrderType`·`OperationsRequestType`, 타입은 `PriorityType` =
483
+ * `NumericType` 제한). 즉 표준은 **숫자라는 것만 정하고 방향은 정하지 않는다.**
484
+ *
485
+ * **그래서 방향은 우리가 정한다: 작은 값이 급하다(1 = 가장 급함).** 흔한 관행이고, 무엇보다
486
+ * 한쪽으로 고정해 두지 않으면 소비처마다 반대로 읽는다. 우리가 정한 규약이라는 사실을 여기 밝힌다.
487
+ *
488
+ * 미지정은 **0 이 아니라 "우선순위 없음"** 이다 — 선언한 것들 뒤에 선다(0 으로 채우면 미지정이
489
+ * 가장 급한 것이 된다).
490
+ */
491
+ export const PRIORITY_UNSET = Number.POSITIVE_INFINITY;
492
+ /** 정렬 키 — 미지정을 맨 뒤로 보낸다. 같은 우선순위는 **입력 순서**를 지킨다(결정성). */
493
+ export function priorityRank(p) {
494
+ return typeof p === 'number' && Number.isFinite(p) ? p : PRIORITY_UNSET;
495
+ }
496
+ /**
497
+ * 납기 대비 상태 — **파생**이다(저장하지 않는다). `locationStatusOf` 와 같은 규율.
498
+ *
499
+ * 예정 창(`endTime`)이 없으면 `undefined` — **"늦지 않았다" 가 아니라 "판단할 수 없다"** 다.
500
+ * 납기가 없는데 정시라고 말하면 그건 없는 사실을 만드는 것이다.
501
+ */
502
+ export function dueStatusOf(x, nowIso) {
503
+ if (!x.endTime || !nowIso)
504
+ return undefined;
505
+ const due = parsedMs(x.endTime);
506
+ const now = parsedMs(nowIso);
507
+ if (!Number.isFinite(due) || !Number.isFinite(now))
508
+ return undefined;
509
+ return now > due ? 'late' : 'on-time';
510
+ }
511
+ /**
512
+ * 선언된 단위로 수량을 읽는다 — **환산하지 않는다.**
513
+ *
514
+ * 없으면 `undefined`: "0" 이 아니고 "계산한 값" 도 아니다. 환산 계수를 모르는데 값을 만들면
515
+ * 그 뒤 모든 계산이 거짓 위에 선다.
516
+ */
517
+ /**
518
+ * 로트의 부분 식별자를 **한 규칙으로** 만든다 — 표준 `MaterialSubLot.ID`.
519
+ *
520
+ * 시뮬(생산)과 미러(관측)가 각자 만들면 같은 부분이 다른 이름을 갖고, 두 구동이 어긋난다
521
+ * (적합성 하네스가 실제로 잡았다). 부분을 구분하는 것은 **자리**다.
522
+ */
523
+ /**
524
+ * 물품을 구별하는 키 — 직렬 물품은 `epc`, 로트의 부분은 `subLotId`(표준 `MaterialSubLot.ID`).
525
+ *
526
+ * **한 곳에서 정한다.** 소비처마다 `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 합쳐지고,
527
+ * 그 순간 재고가 조용히 줄어든다(실제로 그랬다 — §ItemState.subLotId).
528
+ *
529
+ * ── 규약 (2026-08-21에 확정) ────────────────────────────────────────────────
530
+ * ① 물품 맵의 **키는 이 함수의 결과**다. 시뮬·미러 어느 쪽도 규칙을 인라인으로 다시 적지 않는다
531
+ * (미러가 `subLotId ?? epc` 를 세 곳에서 다시 적고 있었고, 그런 중복은 한쪽만 고쳐진다).
532
+ * ② **상태에 실리는 참조도 그 키**다(`TaskState.itemRefs` · `FlowTask.itemEpc`). 그래야 찾기가
533
+ * `get` 한 번으로 끝난다. 직렬 물품에서는 키가 곧 `epc` 이므로 대부분의 경로는 이미 그렇다.
534
+ * ③ 예외는 하나다: **원본이 준 참조**(운영 사실 유입·외부 씨앗)는 우리 키 규칙을 모른다. 그때만
535
+ * `FlowEngine.itemByRef` 의 대체 경로(전수 조회)를 지나고, 그 횟수를 `refScanCount()` 가 센다.
536
+ * 「대체 경로는 드물 것이다」를 짐작하지 않기 위해서다 — 인덱스를 얹을 근거는 그 수다.
537
+ *
538
+ * 왜 참조를 키로 통일하고 인덱스를 먼저 얹지 않았나: 키 규약이 두 갈래인 채로 인덱스를 얹으면
539
+ * **그 질문이 닫힌다**(두 갈래를 전제한 구조가 굳는다). 규약을 먼저 정하고, 남는 비용을 재서 넣는다.
540
+ */
541
+ export function subLotIdOf(classUri, location) {
542
+ return `${classUri}@${location}`;
543
+ }
544
+ export function itemKeyOf(item) {
545
+ return item.subLotId ?? item.epc;
546
+ }
547
+ export function quantityIn(item, uom, definitions) {
548
+ const hit = item.quantities?.find(q => (q.uom ?? undefined) === (uom ?? undefined));
549
+ if (hit)
550
+ return hit.value;
551
+ /* 주 수량이 그 단위면 그것을 답한다 — `quantities` 를 안 실은 물품도 답이 나오게. */
552
+ if ((item.uom ?? undefined) === (uom ?? undefined))
553
+ return item.qty;
554
+ /* **선언된 계수가 있으면** 환산한다 — 여전히 지어내지는 않는다(§conversionFactorOf). */
555
+ return convertQuantity(item, uom, definitions);
556
+ }
557
+ /**
558
+ * **우리가 정한 품목 속성 이름** — 표준은 자리만 정하고 이름을 정하지 않는다.
559
+ *
560
+ * `perBaseUnit`: 값은 **기준 단위 하나당 그 단위의 양**이고, 단위는 속성의 `uom` 이 말한다.
561
+ * 예) 한 개(EA)가 2.5 kg 이면 `{ id: 'perBaseUnit', value: 2.5, uom: 'KGM' }`.
562
+ *
563
+ * **왜 이 모양인가**: 임의의 단위쌍 환산표(CS↔KGM↔EA…)는 품목마다 다르고 연쇄가 필요해 금세
564
+ * 커진다. 현장에서 실제로 필요한 것은 **"세는 단위에서 다른 단위로"** 이므로, 기준 단위를 축으로
565
+ * 두면 선언 한 줄로 끝난다. 기준 단위가 아닌 수량에서 출발하는 환산은 **하지 않는다**(§convertQuantity).
566
+ */
567
+ export const MATERIAL_PROPERTY = {
568
+ /** 기준 단위 하나당 이 단위의 양. `uom` 이 대상 단위. */
569
+ perBaseUnit: 'perBaseUnit'
570
+ };
571
+ /**
572
+ * 이 품목에서 `uom` 으로 가는 계수 — **선언된 것만.** 없으면 `undefined`(추정하지 않는다).
573
+ */
574
+ export function conversionFactorOf(def, uom) {
575
+ if (!def || !uom)
576
+ return undefined;
577
+ for (const p of def.properties ?? []) {
578
+ if (p.id !== MATERIAL_PROPERTY.perBaseUnit || p.uom !== uom)
579
+ continue;
580
+ const n = typeof p.value === 'number' ? p.value : Number(p.value);
581
+ if (Number.isFinite(n) && n > 0)
582
+ return n;
583
+ }
584
+ return undefined;
585
+ }
586
+ /**
587
+ * 선언된 계수로 환산한다 — **기준 수량에서만 출발한다.**
588
+ *
589
+ * 기준 수량(`qty`)은 품목을 세는 단위다(EPCIS 는 단위 미지정이면 개수로 읽는다). 그것이 아닌 수량에서
590
+ * 출발하려면 그 단위→기준 단위 역계수가 또 필요한데, 그것을 짐작하면 오차가 곱으로 쌓인다.
591
+ * 그래서 **기준 수량이 없거나 이미 다른 단위로 선언돼 있으면 환산하지 않는다.**
592
+ */
593
+ function convertQuantity(item, uom, definitions) {
594
+ if (!definitions || !uom)
595
+ return undefined;
596
+ if (typeof item.qty !== 'number' || !Number.isFinite(item.qty))
597
+ return undefined;
598
+ if (item.uom)
599
+ return undefined; // 기준 단위가 아닌 수량 — 역계수를 지어내지 않는다
600
+ const def = definitions.get(item.definitionId ?? item.gtin ?? '');
601
+ const factor = conversionFactorOf(def, uom);
602
+ return factor === undefined ? undefined : item.qty * factor;
603
+ }
604
+ /** `HH:MM` → 자정 이후 분. 형식이 아니면 `undefined`(짐작해 고치지 않는다). */
605
+ function minutesOfDay(hhmm) {
606
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm ?? '');
607
+ if (!m)
608
+ return undefined;
609
+ const h = Number(m[1]);
610
+ const mi = Number(m[2]);
611
+ if (h > 23 || mi > 59)
612
+ return undefined;
613
+ return h * 60 + mi;
614
+ }
615
+ /**
616
+ * 이 시각이 근무 시간인가 — **비근무가 근무를 이긴다.**
617
+ *
618
+ * 표준은 구간과 종류를 정하지만 **겹칠 때 무엇이 이기는지는 정하지 않는다.** 그래서 우리가 정했다:
619
+ * 휴일·정비(비근무)는 교대(근무) 위에 얹힌다. 반대로 두면 휴일 선언이 무의미해진다.
620
+ *
621
+ * 캘린더가 비어 있으면 `true` — **24시간 가용이 기존 거동**이고, 선언하지 않은 것을 쉬는 것으로
622
+ * 읽으면 아무 일도 일어나지 않는 트윈이 된다.
623
+ */
624
+ /** 절대 구간을 쓰는 항목인가 — 되풀이(`HH:MM`)와 판정 방법이 다르다. */
625
+ const isAbsolute = (e) => !!(e.startDateTime || e.finishDateTime);
626
+ /** **날짜를 알아야** 판정할 수 있는 항목인가 — 절대 구간이거나 요일이 걸린 것. */
627
+ const needsDate = (e) => isAbsolute(e) || !!e.daysOfWeek?.length;
628
+ /**
629
+ * 이 구간이 **선언된 요일에 시작한 것**인가 — 자정을 넘는 교대를 바르게 세기 위한 규칙.
630
+ *
631
+ * 22:00→06:00 교대에 월~금을 주면 뜻은 "월~금에 **시작**한다" 이다. 그래서 토요일 새벽 2시는
632
+ * **금요일에 시작한 교대**의 일부이고(포함), 월요일 새벽 2시는 일요일 밤에 서지 않은 교대이므로
633
+ * 제외된다. 순간의 요일로 세면 이 둘이 정확히 반대로 틀린다.
634
+ */
635
+ function onDeclaredDay(e, minuteOfDay, weekday) {
636
+ if (!e.daysOfWeek?.length)
637
+ return true;
638
+ const from = minutesOfDay(e.fromTime);
639
+ const to = minutesOfDay(e.toTime);
640
+ const crosses = from !== undefined && to !== undefined && from > to;
641
+ /* 자정을 넘는 구간의 **뒷조각**(자정~종료)은 어제 시작한 것이다. */
642
+ const startedDay = crosses && minuteOfDay < to ? (weekday + 6) % 7 : weekday;
643
+ return e.daysOfWeek.includes(startedDay);
644
+ }
645
+ /** 선언된 기준의 요일(0 일요일 … 6 토요일). */
646
+ export function weekdayAt(ms, utcOffsetMinutes) {
647
+ return new Date(ms + (utcOffsetMinutes ?? 0) * 60_000).getUTCDay();
648
+ }
649
+ /**
650
+ * 선언된 기준으로 **며칠째인가** — 하루 단위 셈의 경계를 정하는 데 쓴다(기간 발전량 등).
651
+ *
652
+ * 요일·분과 같은 규칙이다: 선언된 오프셋을 더한 뒤 UTC 로 읽는다. 선언이 없으면 UTC 의 하루다.
653
+ * 고정 오프셋이므로 일광절약시간을 쓰는 현장에서는 계절에 따라 한 시간 어긋난다 — 그 현장이 생기면
654
+ * 오프셋이 아니라 지역 이름(`TwinSpace.timezone`)을 커널까지 내려야 한다.
655
+ */
656
+ export function localDayIndexAt(ms, utcOffsetMinutes) {
657
+ return Math.floor((ms + (utcOffsetMinutes ?? 0) * 60_000) / 86_400_000);
658
+ }
659
+ /** 그 날의 시작(UTC ms) — `localDayIndexAt` 의 역함수. */
660
+ export function localDayStartMs(dayIndex, utcOffsetMinutes) {
661
+ return dayIndex * 86_400_000 - (utcOffsetMinutes ?? 0) * 60_000;
662
+ }
663
+ /**
664
+ * 절대 구간이 그 시각을 덮는가 — 한쪽만 있으면 그쪽만 본다(열린 구간).
665
+ * 깨진 시각으로는 판정하지 않는다(짐작해 고치지 않는다).
666
+ */
667
+ function coversInstant(e, atMs) {
668
+ const from = e.startDateTime ? Date.parse(e.startDateTime) : NaN;
669
+ const to = e.finishDateTime ? Date.parse(e.finishDateTime) : NaN;
670
+ if (!Number.isFinite(from) && !Number.isFinite(to))
671
+ return false;
672
+ if (Number.isFinite(from) && atMs < from)
673
+ return false;
674
+ if (Number.isFinite(to) && atMs >= to)
675
+ return false;
676
+ return true;
677
+ }
678
+ /** 이 구간이 그 분을 덮는가 — `inWorkCalendar` 와 `activeShiftOf` 가 **같은 규칙**을 쓴다. */
679
+ function coversMinute(e, minuteOfDay) {
680
+ const from = minutesOfDay(e.fromTime);
681
+ const to = minutesOfDay(e.toTime);
682
+ if (from === undefined || to === undefined)
683
+ return false; // 깨진 선언으로 판정하지 않는다
684
+ /* 자정을 넘는 구간(22:00→06:00)은 두 조각으로 읽는다 — 날짜 오프셋이 그것을 명시할 때도 같다. */
685
+ return from <= to ? minuteOfDay >= from && minuteOfDay < to : minuteOfDay >= from || minuteOfDay < to;
686
+ }
687
+ export function inWorkCalendar(entries, minuteOfDay) {
688
+ /* 되풀이만 본다 — 절대 구간은 분(minute)만으로 판정할 수 없다(어느 날인지 모른다).
689
+ 시각(ms)을 가진 소비처는 `inWorkCalendarAt` 을 쓴다. */
690
+ return judgeCalendar(entries, e => coversMinute(e, minuteOfDay), false);
691
+ }
692
+ /**
693
+ * 이 **시각**이 근무 시간인가 — 되풀이(`HH:MM`)와 **한 번뿐인 구간**(휴일·정비창)을 함께 본다.
694
+ *
695
+ * 절대 구간은 시각 기준이 필요 없다(ISO 시각에 이미 들어 있다). 되풀이는 **선언된 기준**으로 읽는다.
696
+ * 겹치면 규칙은 하나다 — **비근무가 근무를 이긴다**(휴일이 교대 위에 얹힌다).
697
+ */
698
+ export function inWorkCalendarAt(entries, atMs, utcOffsetMinutes) {
699
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
700
+ const day = weekdayAt(atMs, utcOffsetMinutes);
701
+ const covers = (e) => {
702
+ if (isAbsolute(e))
703
+ return coversInstant(e, atMs);
704
+ if (!coversMinute(e, minute))
705
+ return false;
706
+ /* 요일이 걸려 있으면 **그 구간이 시작한 날**로 센다(자정을 넘는 교대). */
707
+ return onDeclaredDay(e, minute, day);
708
+ };
709
+ return judgeCalendar(entries, covers, true);
710
+ }
711
+ /**
712
+ * 근무/비근무 판정의 **한 규칙** — 덮는 방법만 다르고 우선순위는 같다.
713
+ *
714
+ * `withAbsolute` 가 false 면 절대 구간 항목은 **없는 셈 친다**(판정할 재료가 없다). 그것을 근무로도
715
+ * 비근무로도 세면 둘 다 거짓이 된다 — 모르면 판단하지 않는다.
716
+ */
717
+ function judgeCalendar(entries, covers, withAbsolute) {
718
+ if (!entries?.length)
719
+ return true;
720
+ const usable = withAbsolute ? entries : entries.filter(e => !needsDate(e));
721
+ if (!usable.length)
722
+ return true;
723
+ const working = usable.filter(e => (e.entryType ?? 'working') === 'working');
724
+ const off = usable.filter(e => e.entryType === 'non-working');
725
+ if (off.some(covers))
726
+ return false; // 비근무가 이긴다
727
+ if (!working.length)
728
+ return true; // 비근무만 선언했다면 그 밖은 근무다
729
+ return working.some(covers);
730
+ }
731
+ /**
732
+ * 지금 어느 교대인가 — **근무 구간의 이름**(표준 `WorkCalendarEntryType.ID`).
733
+ *
734
+ * 비근무가 이기는 규칙은 여기서도 같다: 휴게·정비 중이면 **어느 교대도 아니다**(`undefined`).
735
+ * 이름 없는 구간은 이름을 지어내지 않는다 — 교대를 나눠 놓지 않은 현장에서 `'1'` 같은 값을
736
+ * 만들어 붙이면, 그 뒤 모든 교대별 집계가 없는 구분 위에 선다.
737
+ *
738
+ * 겹치는 근무 구간이 여럿이면 **먼저 선언된 것**을 답한다(선언 순서가 현장의 우선순위다).
739
+ */
740
+ export function activeShiftOf(entries, minuteOfDay) {
741
+ if (!entries?.length)
742
+ return undefined;
743
+ if (!inWorkCalendar(entries, minuteOfDay))
744
+ return undefined; // 비근무 중 — 어느 교대도 아니다
745
+ for (const e of entries) {
746
+ if (needsDate(e) || (e.entryType ?? 'working') !== 'working' || !e.id)
747
+ continue;
748
+ if (coversMinute(e, minuteOfDay))
749
+ return e.id;
750
+ }
751
+ return undefined;
752
+ }
753
+ /**
754
+ * 이 **시각**에 어느 교대인가 — 휴일까지 반영한다.
755
+ *
756
+ * 휴일에 일어난 일은 **어느 교대에도 속하지 않는다**(그날 교대는 서지 않았다). 되풀이만 보는
757
+ * `activeShiftOf` 로는 그것을 알 수 없어, 휴일에 찍힌 기록이 평소 교대로 집계됐다.
758
+ */
759
+ export function activeShiftAt(entries, atMs, utcOffsetMinutes) {
760
+ if (!entries?.length)
761
+ return undefined;
762
+ if (!inWorkCalendarAt(entries, atMs, utcOffsetMinutes))
763
+ return undefined;
764
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
765
+ const day = weekdayAt(atMs, utcOffsetMinutes);
766
+ for (const e of entries) {
767
+ if (isAbsolute(e) || (e.entryType ?? 'working') !== 'working' || !e.id)
768
+ continue;
769
+ if (!coversMinute(e, minute))
770
+ continue;
771
+ if (!onDeclaredDay(e, minute, day))
772
+ continue; // 그날 서지 않은 교대(시작한 날로 센다)
773
+ return e.id;
774
+ }
775
+ return undefined;
776
+ }
777
+ /**
778
+ * 절대 시각(ms) → **선언된 기준의** 하루 중 분(0..1439).
779
+ *
780
+ * `HH:MM` 만으로는 "어느 기준의 06시" 인지 알 수 없다. 예전에 이것을 UTC 로 읽어 Rosarito(UTC−7)의
781
+ * 06시 교대가 **7시간 틀렸다.** 선언이 없으면 UTC 이고, 그 기본값을 숨기지 않고 밝힌다.
782
+ */
783
+ export function minuteOfDayAt(ms, utcOffsetMinutes) {
784
+ /*
785
+ * `new Date(...)` 를 만들지 않는다 (2026-08-17 프로파일).
786
+ *
787
+ * 교대 판정이 이것을 자원마다 부르는데, 그때마다 Date 객체를 하나 만들고 버렸다 — 틱 시간의 12% 였다.
788
+ * UTC 기준 「하루 안의 분」은 나눗셈으로 나온다(getUTCHours 도 같은 계산을 한다). 음수 시각(1970 이전)
789
+ * 에서도 같은 답이 되도록 나머지를 한 번 더 올린다.
790
+ */
791
+ const shifted = ms + (utcOffsetMinutes ?? 0) * 60_000;
792
+ const DAY = 86_400_000;
793
+ const inDay = ((shifted % DAY) + DAY) % DAY;
794
+ return Math.floor(inDay / 60_000);
795
+ }
796
+ export function offCalendarReasonAt(r, ms, utcOffsetMinutes) {
797
+ if (!offCalendarAt(r, ms, utcOffsetMinutes))
798
+ return undefined;
799
+ const entries = r.workCalendar;
800
+ if (!entries?.length)
801
+ return 'off-hours'; // 옛 `window` 경로 — 구간 선언이 없다
802
+ const minute = minuteOfDayAt(ms, utcOffsetMinutes);
803
+ const day = weekdayAt(ms, utcOffsetMinutes);
804
+ for (const e of entries) {
805
+ if (e.entryType !== 'non-working')
806
+ continue;
807
+ const covered = isAbsolute(e) ? coversInstant(e, ms) : coversMinute(e, minute) && onDeclaredDay(e, minute, day);
808
+ if (covered)
809
+ return 'non-working';
810
+ }
811
+ return 'off-hours';
812
+ }
813
+ export function offCalendarAt(r, ms, utcOffsetMinutes) {
814
+ const minute = minuteOfDayAt(ms, utcOffsetMinutes);
815
+ if (r.workCalendar?.length)
816
+ return !inWorkCalendarAt(r.workCalendar, ms, utcOffsetMinutes);
817
+ const w = r.window;
818
+ if (!w)
819
+ return false;
820
+ const h = Math.floor(minute / 60);
821
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
822
+ }
823
+ /**
824
+ * 이 자원에게 **필수인 시험 목록** — 등급 상속을 타고 닫아 모은다.
825
+ *
826
+ * 요구는 등급이 말하고(`ResourceClassDef.testSpecificationIds`) 기록은 개체가 든다(`testResults`).
827
+ * 그 사이를 잇는 이 수집이 계약에 있는 이유: **두 구동이 같은 답을 내야 한다.** 시뮬과 미러가 각자
828
+ * 모으면 "이 사람에게 무엇이 필수인가" 가 갈리고, 같은 자원을 한쪽은 막고 다른 쪽은 통과시킨다.
829
+ */
830
+ export function requiredTestsFor(directIds, defs, at) {
831
+ if (!defs?.length)
832
+ return [];
833
+ /*
834
+ * ── 같은 답을 틱마다 다시 닫지 않는다 (2026-08-17 프로파일) ──────────────
835
+ *
836
+ * 이 함수의 답은 **셋에만** 달려 있다: 소속 등급, 등급 정의, 판정 시각. 자원의 상태(고장·교대·시험
837
+ * 결과)는 여기 들어오지 않는다 — 그것은 `capabilityOf` 가 따로 본다. 그런데 능력 판정이 자원마다·
838
+ * 작업마다 이것을 불러, 같은 셋으로 같은 답을 한 틱에 수백 번 다시 만들었다(닫기 + 정의 순회 =
839
+ * 프로파일 21%).
840
+ *
841
+ * 그래서 그 셋을 열쇠로 기억한다. 정의 목록은 배열 **자체**로 구분하므로(`classIndex` 와 같은 규율)
842
+ * 모델이 바뀌면 다른 열쇠가 된다. 시각이 흐르면 열쇠도 바뀌므로 유효기간 판정이 낡지 않는다.
843
+ *
844
+ * 돌려주는 배열은 **읽기 전용으로 다뤄야 한다** — 기억된 배열을 부르는 쪽이 고치면 다음 호출자가
845
+ * 고쳐진 것을 받는다. 지금 모든 소비처는 읽기만 한다(그래서 복사하지 않는다: 복사하면 이 기억의
846
+ * 값이 절반은 사라진다).
847
+ */
848
+ const cache = requiredTestsCache.get(defs) ?? new Map();
849
+ if (!requiredTestsCache.has(defs))
850
+ requiredTestsCache.set(defs, cache);
851
+ const key = `${at ?? ''}\u0000${(directIds ?? []).join('\u0001')}`;
852
+ const hit = cache.get(key);
853
+ if (hit)
854
+ return hit;
855
+ const closure = classClosure(directIds, defs, at);
856
+ const required = [];
857
+ for (const d of defs)
858
+ if (closure.has(d.id))
859
+ required.push(...(d.testSpecificationIds ?? []));
860
+ /* 시각이 열쇠에 들어가므로 이 표는 트윈이 굴러가는 동안 계속 자란다 — 크기를 묶는다. */
861
+ if (cache.size >= REQUIRED_TESTS_MAX)
862
+ cache.clear();
863
+ cache.set(key, required);
864
+ return required;
865
+ }
866
+ const REQUIRED_TESTS_MAX = 512;
867
+ const requiredTestsCache = new WeakMap();
868
+ /**
869
+ * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
870
+ *
871
+ * 부르는 쪽이 시각과 시간대를 준다(커널은 `now()`·`utcOffsetMinutes`, 호스트는 관측 시각). 주지 않으면
872
+ * 시각에 달린 판정(유효기간·교대·시험 만료)은 **하지 않는다** — 모르면 판단하지 않는다는 규율이다.
873
+ *
874
+ * `requiredTests` 는 부르는 쪽이 등급에서 모아 넘긴다(등급 정의를 아는 것은 부르는 쪽이다).
875
+ */
876
+ export function capabilityOf(r, ctx) {
877
+ const at = ctx?.at;
878
+ /* 순서가 뜻이다 — "이미 모델 밖" 이 "고장" 보다 앞선다(폐기한 설비의 고장은 고칠 일이 아니다). */
879
+ const eff = effectivityAt(r, at);
880
+ if (eff === 'not-yet')
881
+ return { available: false, reason: 'not-yet' };
882
+ if (eff === 'expired')
883
+ return { available: false, reason: 'retired' };
884
+ if (r.held)
885
+ return { available: false, reason: 'held' };
886
+ if (r.status === 'down')
887
+ return { available: false, reason: 'down' };
888
+ if (at) {
889
+ const ms = parsedMs(at);
890
+ if (Number.isFinite(ms)) {
891
+ const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
892
+ if (why === 'non-working')
893
+ return { available: false, reason: 'resting' };
894
+ if (why === 'off-hours')
895
+ return { available: false, reason: 'off-shift' };
896
+ }
897
+ }
898
+ /* 자격은 **결과가 선언됐을 때만** 제약이다(§meetsTests) — 없는 것으로 막으면 라인이 굶는다. */
899
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
900
+ return { available: false, reason: 'test-expired' };
901
+ if (r.status && r.status !== 'idle' && r.status !== 'available')
902
+ return { available: false, reason: 'working' };
903
+ return { available: true, reason: 'available' };
904
+ }
905
+ /**
906
+ * **이 시각에 이 자리에서 참인 관측** — 구간을 든 관측을 시각으로 찾는다.
907
+ *
908
+ * 「그때 그 방이 몇 도였나」에 답하는 첫 칸이다. 점 관측은 그 시각에만, 구간 관측은 구간 안에서 참이다.
909
+ * 여러 개가 겹치면 **가장 늦게 시작한 것**이 답이다(정정이 나중에 온다).
910
+ *
911
+ * 커널은 값이 옳은지 **판정하지 않는다** — 기준의 표현식은 문법이 정의되지 않은 자유 문자열이다
912
+ * (§`TestSpecificationCriterion`). 여기서 하는 일은 짝을 찾아 주는 것까지다.
913
+ */
914
+ export function observationAt(observations, propertyId, at) {
915
+ const t = parsedMs(at);
916
+ if (!(t >= 0))
917
+ return undefined;
918
+ let best;
919
+ let bestStart = -1;
920
+ for (const o of observations ?? []) {
921
+ if (o.propertyId !== propertyId)
922
+ continue;
923
+ const from = parsedMs(o.effectiveTime);
924
+ if (!(from >= 0) || from > t)
925
+ continue;
926
+ /* 구간이 있으면 그 안이어야 한다. 없으면 그 시점의 값이고, 그 뒤로도 정정될 때까지 유효하다. */
927
+ if (o.effectiveEndTime) {
928
+ const to = parsedMs(o.effectiveEndTime);
929
+ if (to >= 0 && to < t)
930
+ continue;
931
+ }
932
+ if (from > bestStart) {
933
+ best = o;
934
+ bestStart = from;
935
+ }
936
+ }
937
+ return best;
938
+ }
939
+ // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
940
+ // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
941
+ // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
942
+ // (execution-model.md §5, roadmap 발견 gap: 운영 델타 이벤트화)
943
+ export const OP_EVENT = {
944
+ task: 'task.status',
945
+ equipment: 'equipment.status',
946
+ /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
947
+ person: 'person.status',
948
+ /** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
949
+ asset: 'asset.status',
950
+ order: 'order.status',
951
+ quality: 'quality.output', // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
952
+ /**
953
+ * **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
954
+ *
955
+ * `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
956
+ * 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
957
+ *
958
+ * **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
959
+ * 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
960
+ *
961
+ * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
962
+ * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
963
+ */
964
+ test: 'test.result',
965
+ /**
966
+ * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
967
+ *
968
+ * ── 판정과 다른 사실이다 (2026-08-30) ─────────────────────────────────────
969
+ * `test.result` 에 필드로 붙이지 않는다. 셋 중 둘을 표현할 수 없게 되기 때문이다.
970
+ *
971
+ * 한 판정에 처분이 여럿 일부는 재작업하고 일부는 폐기한다
972
+ * 처분 없는 판정 기록만 하고 결정은 나중에 한다
973
+ * 판정 없는 처분 현장 재량으로 뺀다
974
+ *
975
+ * ── 왜 커널이 아는가 ──────────────────────────────────────────────────────
976
+ * 처분은 **자원에 효과를 준다.** 재작업은 자재를 다시 공정에 넣고, 폐기는 재고에서 뺀다. 효과를 주는
977
+ * 사실은 커널 1급이다.
978
+ *
979
+ * EPCIS 의 `disposition` 과 헷갈리지 않는다 — 그것은 **개체의 상태**(`damaged`·`destroyed`)이고
980
+ * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
981
+ */
982
+ disposition: 'nonconformance.disposition',
983
+ /**
984
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
985
+ *
986
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
987
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
988
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
989
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
990
+ *
991
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
992
+ *
993
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
994
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
995
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
996
+ * 사라진다.
997
+ *
998
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
999
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
1000
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
1001
+ * 지워지지 않아야 한다.
1002
+ */
1003
+ complete: 'axis.complete',
1004
+ /**
1005
+ * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
1006
+ *
1007
+ * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
1008
+ * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
1009
+ * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
1010
+ */
1011
+ /*
1012
+ * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
1013
+ * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
1014
+ * 구별되지 않는다.
1015
+ */
1016
+ attentionAck: 'attention.acked',
1017
+ /**
1018
+ * **자리에서 관측된 물리량** — 냉장실 온도·습도, 세척수 유량 같은 것(§`LocationObservation`).
1019
+ *
1020
+ * ── 왜 채널이 필요한가 ────────────────────────────────────────────────────
1021
+ * `LocationState.observations` 를 상태에 두었는데 그것을 낳는 사건이 없었다. 그러면 **상태 ⊆ 이벤트**
1022
+ * 가 깨진다: 채워도 재기동에서 사라지고, 폴드가 되살릴 수 없고, 미러가 이어받을 수도 없다. 상태에만
1023
+ * 있는 축은 조용히 사라지는 축이다.
1024
+ *
1025
+ * 이름을 `energy.measured` 와 같은 결로 둔다 — 그쪽이 에너지 계량의 도착이고 이쪽이 그 일반형이다.
1026
+ * 두 채널을 합치지 않는 이유는 에너지 쪽이 **구간에 누적되는 표본**이라 처리가 다르기 때문이다.
1027
+ */
1028
+ observation: 'location.measured'
1029
+ };
1030
+ // ── 에너지(EMS) 사건 — 네 번째 종류의 어휘 ────────────────────────────────
1031
+ /**
1032
+ * 에너지 트윈의 사건 — **물(物)의 계보가 아니라 스칼라의 시계열.**
1033
+ *
1034
+ * ── 왜 EPCIS 어휘를 쓰지 않나 ──────────────────────────────────────────────
1035
+ * EPCIS 는 "무엇이 어디서 무엇에 일어났나" 를 물건 단위로 말한다. 에너지에는 옮겨 다니는 물건이
1036
+ * 없다 — 계량 지점에서 수치가 변할 뿐이다. 억지로 ObjectEvent 로 감싸면 화면이 "전력 한 개가
1037
+ * 이동했다" 로 읽는다. 그래서 자기 `eventType` 을 갖되 **봉투는 같은 것**을 쓴다
1038
+ * (`CanonicalEnvelope { eventType, data }`) — 저널·리플레이·시간여행을 그대로 얻는다.
1039
+ *
1040
+ * ── 왜 지금 사건만 선언하나 ────────────────────────────────────────────────
1041
+ * 상태 필드(계량 지점의 kW·구간의 계약전력)와 능력(`metered`·`curtailable`)은 **채우는 쪽이
1042
+ * 생길 때** 함께 선언한다. 선언에만 자리를 두고 아무도 옮기지 않으면 그것이 이 프로젝트가 하네스로
1043
+ * 막아 온 바로 그 결함이다(`declaration-in-state`). 사건은 그 검사의 대상이 아니고, 커넥터·커널이
1044
+ * 무엇을 주고받을지 먼저 합의해야 하는 값이라 여기가 그 자리다.
1045
+ *
1046
+ * 설계 근거: `design/profiles/ems.md` §4.
1047
+ */
1048
+ export const ENERGY_EVENT = {
1049
+ /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
1050
+ measured: 'energy.measured',
1051
+ /**
1052
+ * 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
1053
+ *
1054
+ * 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
1055
+ */
1056
+ equipment: 'energy.equipment',
1057
+ /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
1058
+ demandWindow: 'energy.demand.window',
1059
+ /**
1060
+ * **마감된 사용 구간이 도착했다** — 우리가 마감한 것이 아니라 밖에서 확정되어 온 것이다.
1061
+ *
1062
+ * 계량 표본이 오지 않는 현장이 있다. 공급자의 조회나 계량 데이터 시스템이 「이 구간에 X 썼다」를
1063
+ * 이미 마감해 준다. 그 값을 적산(`energy.measured` 의 `kWh`)으로 보내면 커널이 그것을 누적으로
1064
+ * 읽고 차분을 또 구한다 — 차분의 차분이 되어 틀린다. 그래서 다른 사건이다.
1065
+ *
1066
+ * `demandWindow` 와 **합치지 않는다.** 하나는 우리가 표본에서 마감한 것이고 이것은 밖에서 온
1067
+ * 것이다. 두 회계를 더하면 같은 전기를 두 번 센다. 둘 다 있으면 읽는 쪽이 하나를 고르고 어느
1068
+ * 쪽인지 말한다.
1069
+ */
1070
+ usagePeriod: 'energy.usage.period',
1071
+ /**
1072
+ * **발전에 매겨지는 단가가 정해졌다** — 이 기간에 낸 전기 1kWh 의 값.
1073
+ *
1074
+ * 소비에 요금이 매겨지듯 발전에도 값이 매겨진다. 파는 발전소는 판 값이고, 자가소비형은 아꼈다고
1075
+ * 볼 수 있는 값이다 — 어느 쪽인지는 현장이 정한다.
1076
+ *
1077
+ * **단가는 날마다 바뀐다.** 그래서 선언이 아니라 기간을 가진 사실로 받는다. 선언에 적어 두면
1078
+ * 사람이 매일 갱신해야 하고, 안 하면 낡은 값으로 수익이 조용히 틀린다.
1079
+ *
1080
+ * 사건 시각은 기간의 시작이다 — 「이 시각부터 이 단가가 적용된다」이므로 기간이 끝나기 전에 성립한다.
1081
+ *
1082
+ * ── 담지 않은 것 ────────────────────────────────────────────────────────────
1083
+ * 제도마다 있는 가산·인증서·가중치는 담지 않는다. 그 셈은 제도가 정하고 나라마다 다르므로,
1084
+ * 커넥터가 계산해 **1kWh 당 얼마**로 옮겨 보낸다. 커널이 한 제도의 모양을 안으면 다른 제도에서
1085
+ * 그 자리가 거짓이 된다.
1086
+ */
1087
+ generationPrice: 'energy.generation.price',
1088
+ /**
1089
+ * **이 주기에 적용되는 요금 기준이 정해졌다** — 요금적용전력과 기본요금 단가.
1090
+ *
1091
+ * 청구서와 다른 사건이다. 청구서는 끝난 기간의 정산이고 이것은 **지금 적용되는 기준**이다.
1092
+ * 그래서 사건 시각도 다르다 — 청구서는 기간의 끝, 이것은 기간의 시작이다(그때부터 유효하다).
1093
+ *
1094
+ * 이 값은 매 주기 다시 정해진다. 모델의 선언에 적어 두면 사람이 매달 갱신해야 하고, 안 하면 낡은
1095
+ * 값으로 기본요금이 조용히 틀린다. 공급자가 알려 주는 값을 그대로 받는 것이 맞다.
1096
+ *
1097
+ * **금액을 싣지 않는다.** 요금적용전력 × 단가는 커널이 계산한다. 곱을 받으면 그것이 정산인 척한다.
1098
+ */
1099
+ tariffBasis: 'energy.tariff.basis',
1100
+ /**
1101
+ * **청구서가 도착했다** — 공급자가 확정한 금액.
1102
+ *
1103
+ * 우리가 계산한 금액(§`electricityCost`)을 덮지 않는다. 두 값은 다른 것이다: 청구는 사실이고 우리
1104
+ * 계산은 파생이다(구간 요금·예측·가정 비교에 쓴다). 계기 적산과 같은 규율이다 — 다를 때 맞는 쪽은
1105
+ * 그 회계를 가진 쪽이다.
1106
+ *
1107
+ * 어긋나면 **그 사실을 낸다.** 우리 계산이 틀렸다는 뜻이고, 그것을 사람이 찾아내지 않아도 드러나야
1108
+ * 한다(실제로 사람이 찾아낸 적이 있다 — 기본요금을 이 주기의 최대로 매기고 있었다).
1109
+ */
1110
+ bill: 'energy.bill',
1111
+ /**
1112
+ * 피크 경신 — 월 최대 수요가 갱신됐다.
1113
+ *
1114
+ * 구간 마감에서 파생되지만 **사실로 남긴다**: 요금의 근거이고, 나중에 "언제 무엇 때문에 올랐나" 를
1115
+ * 물을 때 파생으로는 답할 수 없다(그 순간의 부하 구성이 사라진다).
1116
+ */
1117
+ peak: 'energy.peak',
1118
+ /** 요금 구간 전환 — 경부하·중간부하·최대부하. 달력이 아니라 사건이다(계절제·특례로 바뀐다). */
1119
+ tariffShift: 'energy.tariff.shift',
1120
+ /** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
1121
+ generated: 'energy.generated',
1122
+ /**
1123
+ * **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
1124
+ *
1125
+ * ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
1126
+ * 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
1127
+ *
1128
+ * 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
1129
+ * 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
1130
+ */
1131
+ generationPeriod: 'energy.generation.period',
1132
+ /** 저장(ESS 충전). */
1133
+ stored: 'energy.stored',
1134
+ /** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
1135
+ discharged: 'energy.discharged',
1136
+ /**
1137
+ * 수요 제어 제안 — **제안이지 명령이 아니다.**
1138
+ *
1139
+ * 이 트윈은 차단·투입을 하지 않는다(안전 계통은 범위 밖: `ems.md` §1). 이대로면 계약을 넘는다는
1140
+ * 판단과 무엇을 줄이면 되는지를 낼 뿐이고, 집행은 사람과 그 시스템의 몫이다. 이름을 `suggested`
1141
+ * 로 둔 이유가 그것이다 — 저널만 보고도 "우리가 끈 것이 아니다" 를 알 수 있어야 한다.
1142
+ */
1143
+ drSuggested: 'energy.dr.suggested'
1144
+ };
1145
+ /**
1146
+ * 값이 어디서 왔나 — **관측의 근거**.
1147
+ *
1148
+ * 같은 kWh 라도 계량기가 잰 것과 공급자 화면에서 긁어온 것은 무게가 다르다. 실제로 긁어온 값이
1149
+ * 망가져 있는 것을 본 적이 있다(다른 현장의 값이 섞이고, 세 자리 수가 한 자리로 왔다). 근거를 값과
1150
+ * 함께 두면 화면이 「이 수는 계량이 아니다」를 말할 수 있다.
1151
+ *
1152
+ * 커널은 이 값으로 판정하지 않는다 — 무엇을 믿을지는 현장이 정한다.
1153
+ */
1154
+ export const OBSERVATION_BASIS = ['metered', 'provider', 'billed', 'estimated'];
1155
+ // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
1156
+ // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
1157
+ export const CMD = {
1158
+ orderHold: 'order.hold',
1159
+ orderResume: 'order.resume',
1160
+ orderRelease: 'order.release',
1161
+ attentionAck: 'attention.ack', // 주목 신호 확인(OPC UA A&C acknowledge) — args:{id}
1162
+ // Operable 코어 — 모든 operable 자원(설비·이동설비) 공통. args:{resourceId}. capability-keyed(무방언, equipment.* 아님).
1163
+ resourceHold: 'resource.hold', // 계획 정지(정비/오프라인) — 배정 스킵
1164
+ resourceResume: 'resource.resume', // 계획 정지 해제
1165
+ resourceDown: 'resource.down', // 비계획 고장 주입 — args:{resourceId, durationMs?}
1166
+ resourceRepair: 'resource.repair', // 즉시 수리
1167
+ resourceResetMetrics: 'resource.reset-metrics', // OEE 계측 창 리셋
1168
+ resourceAdd: 'resource.add' // 라이브 자원(설비) 추가 — args:{kind, homeLocation, count?}. 런타임 구조 변이(what-if 아닌 실제 act)
1169
+ };
1170
+ // ── 보드 청사진 바인딩 (최소) ──────────────────────────────────────────────
1171
+ /**
1172
+ * **저장된 트윈 모델을 읽는 단 하나의 입구.**
1173
+ *
1174
+ * `equipment` 로 개명하기 전에 저장된 트윈 모델은 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
1175
+ * 저장물을 다시 쓰지 않고 **읽을 때 흡수**한다 — 마이그레이션은 되돌리기 어렵고, 읽기 호환은 값싸다.
1176
+ *
1177
+ * 규율 둘:
1178
+ * - 이 함수를 **거치지 않고** `def.equipment` 를 직접 읽는 코드를 두지 않는다. 하나라도 남으면
1179
+ * 그 경로에서만 옛 트윈 모델의 설비가 조용히 사라진다(빈 배열).
1180
+ * - **쓸 때는 새 이름만** 쓴다. 두 이름으로 쓰기 시작하면 저장물에 두 벌이 영구히 섞인다.
1181
+ *
1182
+ * 제거 시점: 저장된 트윈 모델이 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
1183
+ * 사라진다. 그때까지 남겨 두는 이유를 여기 적어 두는 것이 주석의 일이다.
1184
+ */
1185
+ /**
1186
+ * **저장된 트윈 모델의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
1187
+ *
1188
+ * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 트윈 모델은 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
1189
+ * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
1190
+ * 옛 트윈 모델의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1191
+ */
1192
+ export function readBoardLocations(def) {
1193
+ const d = def; // vocabulary-guard: allow — 옛 키 캐스트
1194
+ return d.locations ?? d.nodes ?? []; // vocabulary-guard: allow — 옛 키 흡수
1195
+ }
1196
+ export function readBoardEquipment(def) {
1197
+ /* `movers` 는 개명 **이전의 이름**이다(movers → equipmentList → equipment). 저장된 보드에는 세 세대가
1198
+ 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 트윈 모델은 **설비가 0인 공장**으로 읽힌다 —
1199
+ 오류 없이. 실제로 그랬다: 화면의 설비 수가 0이고, 용량 판정에 자원이 없고, 카탈로그 통합 테스트가
1200
+ "완제품 0" 으로 떨어졌다. 셋 다 원인이 이 한 줄이었다. */
1201
+ const d = def; // vocabulary-guard: allow — 옛 키를 읽어야 하는 자리
1202
+ const list = d.equipment ?? d.equipmentList ?? d.movers ?? []; // vocabulary-guard: allow — 옛 키 흡수
1203
+ /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 트윈 모델이 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
1204
+ 배열만 흡수하고 안쪽 키를 놓치면 설비는 나타나지만 **소속이 전부 비어** 롤업이 통째로 사라진다. */
1205
+ return list.map(e => normalizeHomeLocation(e));
1206
+ }
1207
+ /** 소속 자리 키 정규화 — 설비·자산이 같은 규칙을 쓴다(둘 다 옛 이름이 저장돼 있다). */
1208
+ function normalizeHomeLocation(entry) {
1209
+ const legacy = entry.homeNode; // vocabulary-guard: allow — 옛 키를 읽어야 하는 자리
1210
+ if (legacy === undefined || entry.homeLocation !== undefined)
1211
+ return entry;
1212
+ const { homeNode: _drop, ...rest } = entry; // vocabulary-guard: allow — 옛 키 제거
1213
+ return { ...rest, homeLocation: legacy };
1214
+ }
1215
+ /** 저장된 트윈 모델의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1216
+ export function readBoardAssets(def) {
1217
+ const list = (def.assets ?? []);
1218
+ return list.map(a => normalizeHomeLocation(a));
1219
+ }
1220
+ /**
1221
+ * 선언에서 근거를 **파생한다** — 새 사실을 만들지 않는다.
1222
+ *
1223
+ * `companyPrefix` 가 있다는 사실을 `issued` 로 읽지 **않는다**: 그 값이 이 회사에 발급되었다는 뜻이
1224
+ * 아니기 때문이다(우리 픽스처가 반례다 — GS1 이 2022 년에 버린 예제 프리픽스 `0614141` 이 그 자리에
1225
+ * 있었다). 발급은 모델이 **따로 말해야** 하고, 그것도 주장이다.
1226
+ */
1227
+ export function identityGroundingOf(spec, kernelConstantPrefix) {
1228
+ const declared = spec?.identity?.namespaces?.filter(n => typeof n === 'string' && n.length > 0) ?? [];
1229
+ if (spec?.identity?.issuedClaim && declared.length) {
1230
+ return { grounding: 'issued', basis: 'claimed-issued', namespaces: [...declared] };
1231
+ }
1232
+ if (declared.length)
1233
+ return { grounding: 'declared', basis: 'declared-namespace', namespaces: [...declared] };
1234
+ if (spec?.companyPrefix && spec.binding) {
1235
+ /* 형식은 GS1 이지만 출처는 모델이다 — 그 프리픽스가 발급되었다는 근거가 우리에게 없다. */
1236
+ return { grounding: 'declared', basis: 'gs1-shaped', namespaces: gs1Namespaces(spec.companyPrefix) };
1237
+ }
1238
+ /* 선언이 없다 — 이 트윈은 커널 상수로 돈다. 그 사실을 값으로 말한다. */
1239
+ return {
1240
+ grounding: 'fabricated',
1241
+ basis: 'kernel-constant',
1242
+ namespaces: kernelConstantPrefix ? gs1Namespaces(kernelConstantPrefix) : []
1243
+ };
1244
+ }
1245
+ /** 회사 프리픽스가 정하는 GS1 이름공간들 — 커널이 값을 고르지 않고 선언을 펼친다. */
1246
+ function gs1Namespaces(prefix) {
1247
+ return [`urn:epc:idpat:sgtin:${prefix}.`, `urn:epc:class:lgtin:${prefix}.`, `urn:epc:id:sgtin:${prefix}.`];
1248
+ }