@operato/twin-kernel 0.6.13 → 0.7.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.
package/dist/contract.js CHANGED
@@ -60,7 +60,18 @@ export function isEquipmentLevel(v) {
60
60
  * 계층 색인을 만든다. **순환은 만들 때 잡고 던진다** — 렌더 도중에 터지는 대신 여기서 한 번에.
61
61
  * 순환을 조용히 잘라 내면 롤업이 틀린 값을 내고, 그건 이 함수가 막으려는 바로 그 실패다.
62
62
  */
63
- export function hierarchyOf(s) {
63
+ export function hierarchyOf(s,
64
+ /**
65
+ * 자리가 단(`level`)을 적지 않았을 때 **타입으로 알아내는 해석기** — 카탈로그가 주입한다.
66
+ *
67
+ * 왜 주입인가: 단은 타입의 성질이라 카탈로그가 선언하는데(`TwinTypeInfo.level`), 이 파일은
68
+ * 카탈로그보다 아래에 있다(카탈로그가 이 파일을 읽는다). 여기서 카탈로그를 부르면 순환이 된다.
69
+ *
70
+ * 왜 필요한가: 실측(2026-08-14) 로케이션 82개 중 `level` 을 적은 것이 **0개**였다. 모델이 적어 줄
71
+ * 때까지 기다리면 `ancestorOfLevel` 은 계속 `undefined` 를 답한다 — 선언은 있고 답은 없는 상태다.
72
+ * 자리가 적었으면 그것이 권위이고(현장이 우리보다 자기 계층을 잘 안다), 없으면 타입이 답한다.
73
+ */
74
+ levelOfType) {
64
75
  const push = (m, k, v) => {
65
76
  const cur = m.get(k);
66
77
  if (cur)
@@ -113,7 +124,8 @@ export function hierarchyOf(s) {
113
124
  return out;
114
125
  };
115
126
  const typeOf = new Map(s.locations.map(n => [n.id, n.type]));
116
- const levelOf = new Map(s.locations.map(n => [n.id, n.level]));
127
+ /* 자리의 선언이 먼저, 없으면 타입 — 어느 쪽으로 알았는지는 소비처가 물을 일이 없다(같은 사실이다). */
128
+ const levelOf = new Map(s.locations.map(n => [n.id, n.level ?? (n.type ? levelOfType?.(n.type) : undefined)]));
117
129
  return {
118
130
  childrenOf: id => [...(kids.get(id) ?? [])],
119
131
  ancestorsOf,
@@ -187,7 +199,7 @@ export function effectivityAt(p, at) {
187
199
  * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
188
200
  *
189
201
  * 순환은 방문 집합으로 끊는다(잘못된 마스터가 무한 루프를 만들지 않게). 등급 정의가 없으면 소속
190
- * 그대로만 본다 — 정의를 요구하지 않는다(정의를 싣지 않은 트윈이 그대로 돌아야 한다).
202
+ * 그대로만 본다 — 정의를 요구하지 않는다(정의를 싣지 않은 트윈이 그대로 동작해야 한다).
191
203
  *
192
204
  * `at` 를 주면 **유효 기간 밖의 등급은 제외**한다. 안 주면 기간을 보지 않는다(모르면 판단하지 않는다).
193
205
  */
@@ -215,7 +227,7 @@ export function classClosure(directIds, defs, at) {
215
227
  * `NumericType` 제한). 즉 표준은 **숫자라는 것만 정하고 방향은 정하지 않는다.**
216
228
  *
217
229
  * **그래서 방향은 우리가 정한다: 작은 값이 급하다(1 = 가장 급함).** 흔한 관행이고, 무엇보다
218
- * 한쪽으로 박아 두지 않으면 소비처마다 반대로 읽는다. 우리가 정한 규약이라는 사실을 여기 밝힌다.
230
+ * 한쪽으로 고정해 두지 않으면 소비처마다 반대로 읽는다. 우리가 정한 규약이라는 사실을 여기 밝힌다.
219
231
  *
220
232
  * 미지정은 **0 이 아니라 "우선순위 없음"** 이다 — 선언한 것들 뒤에 선다(0 으로 채우면 미지정이
221
233
  * 가장 급한 것이 된다).
@@ -249,8 +261,8 @@ export function dueStatusOf(x, nowIso) {
249
261
  /**
250
262
  * 로트의 부분 식별자를 **한 규칙으로** 만든다 — 표준 `MaterialSubLot.ID`.
251
263
  *
252
- * 시뮬(생산)과 미러(관측)가 각자 만들면 같은 부분이 다른 이름을 갖고, 두 구동이 갈라진다
253
- * (적합성 하네스가 실제로 잡았다). 부분을 가르는 것은 **자리**다.
264
+ * 시뮬(생산)과 미러(관측)가 각자 만들면 같은 부분이 다른 이름을 갖고, 두 구동이 어긋난다
265
+ * (적합성 하네스가 실제로 잡았다). 부분을 구분하는 것은 **자리**다.
254
266
  */
255
267
  /**
256
268
  * 물품을 구별하는 키 — 직렬 물품은 `epc`, 로트의 부분은 `subLotId`(표준 `MaterialSubLot.ID`).
@@ -589,7 +601,7 @@ export const OP_EVENT = {
589
601
  *
590
602
  * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
591
603
  * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
592
- * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
604
+ * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
593
605
  */
594
606
  /*
595
607
  * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
@@ -598,6 +610,53 @@ export const OP_EVENT = {
598
610
  */
599
611
  attentionAck: 'attention.acked'
600
612
  };
613
+ // ── 에너지(EMS) 사건 — 네 번째 종류의 어휘 ────────────────────────────────
614
+ /**
615
+ * 에너지 트윈의 사건 — **물(物)의 계보가 아니라 스칼라의 시계열.**
616
+ *
617
+ * ── 왜 EPCIS 어휘를 쓰지 않나 ──────────────────────────────────────────────
618
+ * EPCIS 는 "무엇이 어디서 무엇에 일어났나" 를 물건 단위로 말한다. 에너지에는 옮겨 다니는 물건이
619
+ * 없다 — 계량 지점에서 수치가 변할 뿐이다. 억지로 ObjectEvent 로 감싸면 화면이 "전력 한 개가
620
+ * 이동했다" 로 읽는다. 그래서 자기 `eventType` 을 갖되 **봉투는 같은 것**을 쓴다
621
+ * (`CanonicalEnvelope { eventType, data }`) — 저널·리플레이·시간여행을 그대로 얻는다.
622
+ *
623
+ * ── 왜 지금 사건만 선언하나 ────────────────────────────────────────────────
624
+ * 상태 필드(계량 지점의 kW·구간의 계약전력)와 능력(`metered`·`curtailable`)은 **채우는 쪽이
625
+ * 생길 때** 함께 선언한다. 선언에만 자리를 두고 아무도 옮기지 않으면 그것이 이 프로젝트가 하네스로
626
+ * 막아 온 바로 그 결함이다(`declaration-in-state`). 사건은 그 검사의 대상이 아니고, 커넥터·커널이
627
+ * 무엇을 주고받을지 먼저 합의해야 하는 값이라 여기가 그 자리다.
628
+ *
629
+ * 설계 근거: `design/profiles/ems.md` §4.
630
+ */
631
+ export const ENERGY_EVENT = {
632
+ /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
633
+ measured: 'energy.measured',
634
+ /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
635
+ demandWindow: 'energy.demand.window',
636
+ /**
637
+ * 피크 경신 — 월 최대 수요가 갱신됐다.
638
+ *
639
+ * 구간 마감에서 파생되지만 **사실로 남긴다**: 요금의 근거이고, 나중에 "언제 무엇 때문에 올랐나" 를
640
+ * 물을 때 파생으로는 답할 수 없다(그 순간의 부하 구성이 사라진다).
641
+ */
642
+ peak: 'energy.peak',
643
+ /** 요금 구간 전환 — 경부하·중간부하·최대부하. 달력이 아니라 사건이다(계절제·특례로 바뀐다). */
644
+ tariffShift: 'energy.tariff.shift',
645
+ /** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
646
+ generated: 'energy.generated',
647
+ /** 저장(ESS 충전). */
648
+ stored: 'energy.stored',
649
+ /** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
650
+ discharged: 'energy.discharged',
651
+ /**
652
+ * 수요 제어 제안 — **제안이지 명령이 아니다.**
653
+ *
654
+ * 이 트윈은 차단·투입을 하지 않는다(안전 계통은 범위 밖: `ems.md` §1). 이대로면 계약을 넘는다는
655
+ * 판단과 무엇을 줄이면 되는지를 낼 뿐이고, 집행은 사람과 그 시스템의 몫이다. 이름을 `suggested`
656
+ * 로 둔 이유가 그것이다 — 저널만 보고도 "우리가 끈 것이 아니다" 를 알 수 있어야 한다.
657
+ */
658
+ drSuggested: 'energy.dr.suggested'
659
+ };
601
660
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
602
661
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
603
662
  export const CMD = {
@@ -36,7 +36,7 @@ export interface CounterfactualOptions {
36
36
  tickMs?: number;
37
37
  }
38
38
  /**
39
- * 반사실: history 의 시각 T 상태에서 두 분기를 굴려 대안의 효과를 잰다.
39
+ * 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
40
40
  * withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
41
41
  */
42
42
  export declare function counterfactualAt(history: TwinHistory, atSimMs: number, opts: CounterfactualOptions): CounterfactualResult | undefined;
@@ -2,7 +2,7 @@
2
2
  * Counterfactual + Checkpointing — "과거 T에 다른 결정을 했다면?" (기억 + 분기 결합).
3
3
  *
4
4
  * replay(기억)는 과거를 재구성만 한다. 여기에 fork(분기)를 결합하면 반사실 분석:
5
- * 과거 시점으로 되돌아가 대안 결정을 넣고 앞으로 굴려, 실제(대안 없음)와 대조 → 대안의 효과.
5
+ * 과거 시점으로 되돌아가 대안 결정을 넣고 앞으로 시뮬레이션해, 실제(대안 없음)와 대조 → 대안의 효과.
6
6
  * 전체 커널 상태가 필요하므로(replay 의 projected 상태로는 부족) 주기적 checkpoint(fork 저장)를 쓴다.
7
7
  * 체크포인트 fork 를 정확히 T 로 재구동(RNG 연속 → 실제와 일치)한 뒤 분기 = 결정적 반사실.
8
8
  */
@@ -43,7 +43,7 @@ export class TwinHistory {
43
43
  }
44
44
  }
45
45
  /**
46
- * 반사실: history 의 시각 T 상태에서 두 분기를 굴려 대안의 효과를 잰다.
46
+ * 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
47
47
  * withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
48
48
  */
49
49
  export function counterfactualAt(history, atSimMs, opts) {
@@ -1,5 +1,17 @@
1
1
  import type { CapabilityKey } from './capability.ts';
2
- export type DomainSystem = 'wms' | 'yms' | 'mes';
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';
3
15
  export interface TwinTypeInfo {
4
16
  /** 커널 권위 키 (예: 'storage', 'forklift'). */
5
17
  key: string;
@@ -7,15 +19,30 @@ export interface TwinTypeInfo {
7
19
  role: 'location' | 'equipment';
8
20
  label: string;
9
21
  /** ① 표준 온톨로지 투영(03-reference-standards). 열린 문자열 — 하드코딩 enum 금지. */
10
- standardClass: {
11
- epcis?: string;
12
- isa95?: string;
13
- iso55000?: string;
14
- };
22
+ standardClass: StandardClass;
15
23
  /** ④ 식별자 스킴 — id 매칭 UX/검증 힌트(열린 문자열). 예: 'gs1:SGLN' | 'gs1:GIAI' | 'kernel:id'. */
16
24
  identity: {
17
25
  scheme: string;
18
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;
19
46
  /** ② 능력 프로파일(ADR-0018 정제) — 이 타입이 무엇을 하나. 엔티티→capability→컴포넌트 매핑의 SSOT. */
20
47
  capabilities: CapabilityKey[];
21
48
  }
@@ -31,7 +58,7 @@ export declare const DOMAIN_CATALOG: Record<DomainSystem, DomainProfileInfo>;
31
58
  export interface TwinAxisInfo {
32
59
  /**
33
60
  * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `TwinModelDef` 의 키 **그대로**다
34
- * (저장·계약의 이름과 갈라지면 그 순간 방언이 생긴다).
61
+ * (저장·계약의 이름과 어긋나면 그 순간 방언이 생긴다).
35
62
  */
36
63
  axis: string;
37
64
  /**
@@ -50,7 +77,7 @@ export interface TwinAxisInfo {
50
77
  */
51
78
  source?: 'document' | 'state' | 'journal';
52
79
  /**
53
- * 이 축이 **시간축을 갖나** — 과거 시점으로 되짚을 수 있는가(저널 폴드).
80
+ * 이 축이 **시간축을 갖나** — 과거 시점으로 다시 계산할 수 있는가(저널 폴드).
54
81
  *
55
82
  * 선언은 시간축이 없다(그 시점의 선언은 구조 리비전이 답한다). 관측은 있다 — 그래서 이 표시가
56
83
  * 필요하다: 화면이 시각 커서를 이 축에 걸어도 되는지 알아야 한다.
@@ -70,20 +97,34 @@ export interface TwinAxisInfo {
70
97
  path?: string;
71
98
  /** i18n 키 — 사람 언어는 표현 계층이 렌더한다(L2). */
72
99
  label: string;
73
- /** 표준 온톨로지 투영. 표준에 자리가 없으면 **빈 객체**(숨기지 않는다). */
74
- standardClass: {
75
- epcis?: string;
76
- isa95?: string;
77
- iso55000?: string;
78
- };
79
100
  /**
80
- * 표준이 **정의(class)와 개체(instance)를 가른다.** 같은 자재라도 `MaterialDefinition`
101
+ * 표준 온톨로지 투영. 표준에 자리가 없으면 **빈 객체**(숨기지 않는다).
102
+ * 키는 표준 하나당 하나다 — 에너지는 ISA-95 가 아니라 ISO 50001·IEC 61850 으로 잰다(`StandardClass`).
103
+ */
104
+ standardClass: StandardClass;
105
+ /**
106
+ * 표준이 **정의(class)와 개체(instance)를 구분한다.** 같은 자재라도 `MaterialDefinition` 과
81
107
  * `MaterialLot` 은 다른 것이다. 화면이 둘을 같은 칸에 담지 않도록 선언에 싣는다.
82
108
  * `spec` 은 자원이 아니라 **명세**(공정·생산 정의).
83
109
  */
84
110
  kind: 'instance' | 'class' | 'spec';
85
111
  /** 이 축의 항목이 카탈로그 타입을 갖는가 — 있으면 `types` 와 이어진다(자리·설비만). */
86
112
  typeRole?: 'location' | 'equipment';
113
+ /**
114
+ * 이 축이 **어느 종류의 트윈에 있는가** — 선언하지 않으면 **전부**에 있다.
115
+ *
116
+ * ── 왜 생겼나 (2026-08-14) ─────────────────────────────────────────────
117
+ * 지금까지 모든 축은 모든 종류에 있다고 **암묵적으로 가정**했다. 창고·야드·공정은 셋 다 로트와
118
+ * 오더와 작업을 가지므로 그 가정이 아프지 않았다. 에너지(EMS)가 처음으로 그것을 깬다 —
119
+ * 옮겨 다니는 물건이 없다(스칼라가 시간 위에서 변한다).
120
+ *
121
+ * 선언하지 않으면 화면은 **없는 것과 해당 없는 것을 구별할 수 없다.** 개념 지도는 「로트 0」을
122
+ * 사실처럼 그리고, 적합성 표는 채우지 못한 것을 결손으로 센다 — 둘 다 거짓말이고, 이 프로젝트가
123
+ * 관측 열에서 내내 지켜 온 구분(`observedAbsence`·`emptyBecause`)과 같은 부류의 실패다.
124
+ *
125
+ * **비워 두는 것이 기본이다**: 대부분의 축은 모든 종류에 있고, 예외만 적는다.
126
+ */
127
+ systems?: DomainSystem[];
87
128
  }
88
129
  /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
89
130
  export declare const TWIN_AXES: TwinAxisInfo[];
@@ -193,5 +234,26 @@ export declare const relationsTo: (axis: string) => TwinRelationInfo[];
193
234
  /** 축 이름 → 서술. 모르는 축이면 `undefined` — 화면은 그것을 "미선언" 으로 낸다(숨기지 않는다). */
194
235
  export declare function axisInfo(axis: string): TwinAxisInfo | undefined;
195
236
  export declare const DOMAIN_SYSTEMS: DomainSystem[];
237
+ /**
238
+ * 이 축이 그 종류의 트윈에 **있는가** — 판정은 여기 하나다.
239
+ *
240
+ * 소비처가 `info.systems` 를 직접 비교하면 "선언하지 않으면 전부" 라는 기본값 규칙이 자리마다
241
+ * 복사되고, 한 곳이 빠지면 그 화면만 다른 답을 낸다(축 하나가 어떤 화면에서는 보이고 어떤
242
+ * 화면에서는 안 보인다). `axisSource` 와 같은 자리, 같은 이유다.
243
+ *
244
+ * **종류를 모르면 있다고 답한다** — 종류를 모르는 채로 축을 숨기면, 화면은 "해당 없음" 과
245
+ * "아직 모름" 을 같게 그리게 된다. 모르는 것을 근거로 감추지 않는다.
246
+ */
247
+ export declare const axisAppliesTo: (axis: string, system?: string) => boolean;
248
+ /**
249
+ * 자리 타입 → **ISA-95 계층 단.** `hierarchyOf` 에 주입해 모델이 단을 적지 않아도 사슬을 걷게 한다.
250
+ *
251
+ * 종류를 알면 그 카탈로그만 본다. 모르면 **모든 종류에서 찾고, 답이 하나일 때만 답한다** — 두 종류가
252
+ * 같은 키를 다른 단으로 선언했다면 그중 하나를 고르는 것은 짐작이고, 짐작한 단으로 롤업하면 그
253
+ * 트윈의 집계가 조용히 틀린다. 그럴 때는 `undefined`(모른다)이고, 시험이 그 충돌 자체를 막는다.
254
+ */
255
+ export declare const levelOfLocationType: (typeKey: string, system?: string) => EquipmentLevel | undefined;
256
+ /** 그 종류의 트윈이 갖는 축들 — 개념 지도·적합성 표가 이 목록으로 그린다. */
257
+ export declare const axesOfSystem: (system?: string) => TwinAxisInfo[];
196
258
  /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
197
259
  export declare function capabilitiesForType(system: DomainSystem, typeKey: string): CapabilityKey[];
@@ -1,13 +1,35 @@
1
1
  import { WMS_TYPES } from "./wms-profile.js";
2
2
  import { YMS_TYPES } from "./yms-profile.js";
3
3
  import { MES_TYPES } from "./mes-profile.js";
4
+ import { EMS_TYPES } from "./ems-profile.js";
4
5
  const locationKeys = (types) => types.filter(t => t.role === 'location').map(t => t.key);
5
6
  export const DOMAIN_CATALOG = {
6
7
  // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
7
8
  wms: { system: 'wms', label: 'twin.system.wms', types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
8
9
  yms: { system: 'yms', label: 'twin.system.yms', types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
9
- mes: { system: 'mes', label: 'twin.system.mes', types: MES_TYPES, locationTypes: locationKeys(MES_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) }
10
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'];
11
33
  /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
12
34
  export const TWIN_AXES = [
13
35
  { axis: 'locations', path: 'locations', label: 'twin.axis.locations', kind: 'instance', typeRole: 'location',
@@ -15,23 +37,23 @@ export const TWIN_AXES = [
15
37
  { axis: 'equipment', path: 'equipment', label: 'twin.axis.equipment', kind: 'instance', typeRole: 'equipment',
16
38
  standardClass: { isa95: 'Equipment', iso55000: 'Asset', epcis: 'GIAI' } },
17
39
  { axis: 'persons', path: 'persons', label: 'twin.axis.persons', kind: 'instance',
18
- standardClass: { isa95: 'Person' } },
40
+ standardClass: { isa95: 'Person' }, systems: LOGISTICS },
19
41
  { axis: 'assets', path: 'assets', label: 'twin.axis.assets', kind: 'instance',
20
42
  standardClass: { isa95: 'PhysicalAsset', iso55000: 'Asset' } },
21
43
  { axis: 'personnelClasses', path: 'personnelClasses', label: 'twin.axis.personnelClasses', kind: 'class',
22
- standardClass: { isa95: 'PersonnelClass' } },
44
+ standardClass: { isa95: 'PersonnelClass' }, systems: LOGISTICS },
23
45
  { axis: 'equipmentClasses', path: 'equipmentClasses', label: 'twin.axis.equipmentClasses', kind: 'class',
24
46
  standardClass: { isa95: 'EquipmentClass' } },
25
47
  { axis: 'assetClasses', path: 'assetClasses', label: 'twin.axis.assetClasses', kind: 'class',
26
48
  standardClass: { isa95: 'PhysicalAssetClass' } },
27
49
  { axis: 'materialDefinitions', path: 'materialDefinitions', label: 'twin.axis.materialDefinitions', kind: 'class',
28
- standardClass: { isa95: 'MaterialDefinition' } },
50
+ standardClass: { isa95: 'MaterialDefinition' }, systems: LOGISTICS },
29
51
  { axis: 'materialClasses', path: 'materialClasses', label: 'twin.axis.materialClasses', kind: 'class',
30
- standardClass: { isa95: 'MaterialClass' } },
52
+ standardClass: { isa95: 'MaterialClass' }, systems: LOGISTICS },
31
53
  { axis: 'operations', path: 'operations', label: 'twin.axis.operations', kind: 'spec',
32
- standardClass: { isa95: 'OperationsSegment' } },
54
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
33
55
  { axis: 'productionSpec', path: 'productionSpec', label: 'twin.axis.productionSpec', kind: 'spec',
34
- standardClass: { isa95: 'OperationsSegment' } },
56
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
35
57
  /*
36
58
  * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
37
59
  * `materials` 가 최상위 `materialDefinitions` 와 **둘 다** 있는 것은 지금 상태 그대로다 —
@@ -39,11 +61,11 @@ export const TWIN_AXES = [
39
61
  * 둘이라는 사실을 **선언이 드러낸다**(감추면 화면이 어느 쪽을 보는지 아무도 모른다).
40
62
  */
41
63
  { axis: 'recipes', path: 'productionSpec.definition.recipes', label: 'twin.axis.recipes', kind: 'spec',
42
- standardClass: { isa95: 'OperationsSegment' } },
64
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
43
65
  { axis: 'routes', path: 'productionSpec.definition.routes', label: 'twin.axis.routes', kind: 'spec',
44
- standardClass: { isa95: 'OperationsSegment' } },
66
+ standardClass: { isa95: 'OperationsSegment' }, systems: LOGISTICS },
45
67
  { axis: 'materials', path: 'productionSpec.definition.materials', label: 'twin.axis.materials', kind: 'class',
46
- standardClass: { isa95: 'MaterialDefinition' } },
68
+ standardClass: { isa95: 'MaterialDefinition' }, systems: LOGISTICS },
47
69
  /*
48
70
  * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
49
71
  *
@@ -72,9 +94,9 @@ export const TWIN_AXES = [
72
94
  * 별도 결정으로 한다.
73
95
  */
74
96
  { axis: 'orders', label: 'twin.axis.orders', kind: 'instance', source: 'state', historical: true,
75
- standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' } },
97
+ standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' }, systems: LOGISTICS },
76
98
  { axis: 'tasks', label: 'twin.axis.tasks', kind: 'instance', source: 'state', historical: true,
77
- standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' } }
99
+ standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS }
78
100
  ];
79
101
  /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
80
102
  export const TWIN_RELATIONS = [
@@ -96,7 +118,7 @@ export const TWIN_RELATIONS = [
96
118
  { from: 'routes', field: 'steps[]', target: { kind: 'axis', axis: 'operations' }, via: 'twin.rel.step' },
97
119
  /*
98
120
  * 일정·실적의 관계 — **관측 관계다.** 선언 관계는 "그렇게 만들기로 했다" 이고, 이것은 "그렇게
99
- * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 있는 카드가 된다(그러면 걸어 들어갈 수 없다).
121
+ * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 연결되지 않은 카드가 된다(그러면 걸어 들어갈 수 없다).
100
122
  */
101
123
  /*
102
124
  * 오더가 무엇을 만드나 — **자재 키가 아니라 GS1 품목 참조다**(`lines[].gtin`). 자재와 이어 주는
@@ -181,7 +203,50 @@ export const relationsTo = (axis) => TWIN_RELATIONS.filter(r => r.target.kind ==
181
203
  export function axisInfo(axis) {
182
204
  return TWIN_AXES.find(a => a.axis === axis);
183
205
  }
184
- export const DOMAIN_SYSTEMS = ['wms', 'yms', 'mes'];
206
+ export const DOMAIN_SYSTEMS = ['wms', 'yms', 'mes', 'ems'];
207
+ /**
208
+ * 이 축이 그 종류의 트윈에 **있는가** — 판정은 여기 하나다.
209
+ *
210
+ * 소비처가 `info.systems` 를 직접 비교하면 "선언하지 않으면 전부" 라는 기본값 규칙이 자리마다
211
+ * 복사되고, 한 곳이 빠지면 그 화면만 다른 답을 낸다(축 하나가 어떤 화면에서는 보이고 어떤
212
+ * 화면에서는 안 보인다). `axisSource` 와 같은 자리, 같은 이유다.
213
+ *
214
+ * **종류를 모르면 있다고 답한다** — 종류를 모르는 채로 축을 숨기면, 화면은 "해당 없음" 과
215
+ * "아직 모름" 을 같게 그리게 된다. 모르는 것을 근거로 감추지 않는다.
216
+ */
217
+ export const axisAppliesTo = (axis, system) => {
218
+ const declared = TWIN_AXES.find(a => a.axis === axis)?.systems;
219
+ if (!declared || !system)
220
+ return true;
221
+ /*
222
+ * **모르는 종류는 감추지 않는다.**
223
+ *
224
+ * 여기서 `includes` 로 곧장 답하면 오타 하나나 새 종류 하나가 축 열한 개를 **조용히 지운다**
225
+ * (개념 지도의 카드가 사라지고, 사용자는 그 트윈이 원래 그런 것이라고 읽는다). 종류를 아예
226
+ * 모르는 경우(`undefined`)에 있다고 답하는 것과 같은 이유다 — 모르는 것을 근거로 감추지 않는다.
227
+ *
228
+ * 등록된 종류가 그 축을 선언하지 않았을 때만 「해당 없음」이다.
229
+ */
230
+ if (!DOMAIN_SYSTEMS.includes(system))
231
+ return true;
232
+ return declared.includes(system);
233
+ };
234
+ /**
235
+ * 자리 타입 → **ISA-95 계층 단.** `hierarchyOf` 에 주입해 모델이 단을 적지 않아도 사슬을 걷게 한다.
236
+ *
237
+ * 종류를 알면 그 카탈로그만 본다. 모르면 **모든 종류에서 찾고, 답이 하나일 때만 답한다** — 두 종류가
238
+ * 같은 키를 다른 단으로 선언했다면 그중 하나를 고르는 것은 짐작이고, 짐작한 단으로 롤업하면 그
239
+ * 트윈의 집계가 조용히 틀린다. 그럴 때는 `undefined`(모른다)이고, 시험이 그 충돌 자체를 막는다.
240
+ */
241
+ export const levelOfLocationType = (typeKey, system) => {
242
+ const pick = (sys) => DOMAIN_CATALOG[sys]?.types.find(t => t.role === 'location' && t.key === typeKey)?.level;
243
+ if (system && DOMAIN_SYSTEMS.includes(system))
244
+ return pick(system);
245
+ const found = [...new Set(DOMAIN_SYSTEMS.map(pick).filter(Boolean))];
246
+ return found.length === 1 ? found[0] : undefined;
247
+ };
248
+ /** 그 종류의 트윈이 갖는 축들 — 개념 지도·적합성 표가 이 목록으로 그린다. */
249
+ export const axesOfSystem = (system) => TWIN_AXES.filter(a => axisAppliesTo(a.axis, system));
185
250
  /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
186
251
  export function capabilitiesForType(system, typeKey) {
187
252
  return DOMAIN_CATALOG[system]?.types.find(t => t.key === typeKey)?.capabilities ?? [];
@@ -1,8 +1,24 @@
1
1
  /** 표준 온톨로지 투영(열린 문자열 — 하드코딩 enum 금지). */
2
+ /**
3
+ * 표준 온톨로지 투영 — 이 개념을 각 표준이 무엇이라 부르나.
4
+ *
5
+ * ── 왜 키가 늘었나 (2026-08-14) ────────────────────────────────────────────
6
+ * 셋(`epcis`·`isa95`·`iso55000`)으로 고정돼 있었다. 물류·생산·자산은 그 셋으로 덮이지만
7
+ * **에너지는 덮이지 않는다** — 계측·설비 모델은 IEC 61850, 성과·관리체계는 ISO 50001 이다.
8
+ * 자리가 없으면 대응을 **적을 수 없고**, 적을 수 없으면 적합성 표가 그 개념을 「표준에 없음」으로
9
+ * 센다 — 없는 것이 아니라 **다른 표준의 것**인데도.
10
+ *
11
+ * 값은 열린 문자열이다(하드코딩 enum 금지). 표준에 자리가 없으면 그 키를 **비운다** —
12
+ * 억지로 가까운 이름을 적으면 적합성 표가 거짓을 말한다.
13
+ */
2
14
  export interface StandardClass {
3
15
  epcis?: string;
4
16
  isa95?: string;
5
17
  iso55000?: string;
18
+ /** ISO 50001 — 에너지 경영. 성과지표(EnPI)·기준선·유의 에너지 사용(SEU) 같은 **역할** 이름. */
19
+ iso50001?: string;
20
+ /** IEC 61850 — 전력 설비 데이터 모델. 논리 노드 이름(예: `MMXU`·`MMTR`·`XCBR`·`ZBAT`). */
21
+ iec61850?: string;
6
22
  }
7
23
  export interface Identity {
8
24
  /** GS1/커널 식별 스킴 힌트. 예: 'gs1:SGLN' | 'gs1:GIAI' | 'gs1:SSCC'. */
@@ -109,7 +125,7 @@ export interface OperationDef {
109
125
  /**
110
126
  * 필요 인원 — **ISA-95 `OperationsSegment.PersonnelSpecification`**(`PersonnelClassID` + `Quantity`) 1:1.
111
127
  *
112
- * 이것이 없으면 사람은 시뮬레이션에 존재하지 않는다 — 설비만 있으면 언제나 돌아가는 공장이 된다.
128
+ * 이것이 없으면 사람은 시뮬레이션에 존재하지 않는다 — 설비만 있으면 언제나 가동되는 공장이 된다.
113
129
  * 현장에서 가장 자주 부족한 자원이 사람인데, 그 부족이 만드는 줄이 예측에서 통째로 사라진다.
114
130
  * 등급(class)으로 요구한다: 특정인을 지목하는 것이 아니라 "용접 자격자 2명" 이다.
115
131
  */
@@ -0,0 +1,121 @@
1
+ import { FlowEngine } from './flow-engine.ts';
2
+ import { type AllocationPolicy } from './allocation-policy.ts';
3
+ import { type CanonicalEnvelope, type StateSnapshot } from './contract.ts';
4
+ /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
5
+ export declare const DEMAND_WINDOW_MS: number;
6
+ /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
7
+ export declare const demandWindowStart: (atMs: number, windowMs?: number) => number;
8
+ /** 계량 지점의 마지막 관측 — 값이 없으면 **모르는 것이다**(0 이 아니다). */
9
+ export interface MeterPointState {
10
+ id: string;
11
+ kW?: number;
12
+ /** 누적 전력량(원천이 준 적산값) — 우리가 적분한 값이 아니다. */
13
+ kWh?: number;
14
+ powerFactor?: number;
15
+ /** 마지막 표본의 시각 — 계측이 끊긴 것을 소비처가 알 수 있게. */
16
+ atMs?: number;
17
+ /** 이 구간에 받은 표본 수 — 「못 쟀다」와 「0이었다」를 구별한다. */
18
+ samplesInWindow: number;
19
+ }
20
+ export interface DemandWindowState {
21
+ startMs: number;
22
+ endMs: number;
23
+ /** 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다). */
24
+ maxKW?: number;
25
+ /** 표본 평균 부하 — 요금 산정의 평균 수요에 대응한다. */
26
+ meanKW?: number;
27
+ /** **kW 를 실은** 표본 수 — 부하 판정의 근거 수다(받은 표본 전체가 아니다). */
28
+ samples: number;
29
+ /** 계약전력 대비 — 계약을 모르면 `undefined`(짐작하지 않는다). */
30
+ contractKW?: number;
31
+ overContract?: boolean;
32
+ }
33
+ export interface EnergyState {
34
+ points: MeterPointState[];
35
+ /** 지금 열려 있는 구간 — 아직 마감되지 않았다(예측은 `projectedKW`). */
36
+ open?: DemandWindowState & {
37
+ projectedKW?: number;
38
+ projectionBasis?: 'mean-so-far';
39
+ };
40
+ /**
41
+ * 마감된 구간들 — **최근 것만 들고 있다**(상태 크기가 시간에 비례하지 않게).
42
+ * 자른 사실을 `closedTotal` 로 함께 낸다 — 조용히 자르지 않는다.
43
+ */
44
+ closed: DemandWindowState[];
45
+ closedTotal: number;
46
+ /** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
47
+ peakSince?: {
48
+ kW: number;
49
+ windowStartMs: number;
50
+ };
51
+ /** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
52
+ contractKW?: number;
53
+ /**
54
+ * 이 커널이 받은 **물류 흐름 요청** — 에너지에는 없는 것들이다(도착·오더·배정·작업 완료).
55
+ * 비어 있지 않으면 배선 오류다: EMS 트윈에 물류 명령이 오고 있다. 조용히 넘기지 않는다.
56
+ */
57
+ flowRequests?: {
58
+ hook: string;
59
+ count: number;
60
+ }[];
61
+ }
62
+ export declare class EmsKernel extends FlowEngine {
63
+ private points;
64
+ private open?;
65
+ private closed;
66
+ private closedTotal;
67
+ private peak?;
68
+ /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
69
+ private suggestedFor?;
70
+ private windowMs;
71
+ constructor(tenantId: string, policy?: AllocationPolicy, windowMs?: number);
72
+ /**
73
+ * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
74
+ *
75
+ * 여러 자리가 선언하면 **가장 큰 값**을 쓴다: 수전 지점의 계약이 분기의 것보다 크고, 트윈 전체의
76
+ * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
77
+ * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
78
+ */
79
+ private declaredContractKW;
80
+ /**
81
+ * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
82
+ *
83
+ * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
84
+ * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
85
+ */
86
+ apply(envelope: CanonicalEnvelope): void;
87
+ private ingestMeasured;
88
+ private openWindow;
89
+ /**
90
+ * 지난 구간을 닫는다 — **표본이 없으면 값을 만들지 않는다.**
91
+ *
92
+ * 침묵한 구간을 「0 kW」로 닫으면 그 트윈은 「그 15분 동안 전기를 쓰지 않았다」고 말하는 것이 된다.
93
+ * 구간이 지난 것은 사실이고 우리가 못 쟀다는 것도 사실이므로, 구간은 남기고 값은 비운다.
94
+ *
95
+ * 표본이 오지 않으면 마감도 오지 않는다(라이브에서 계측이 끊기면 열린 구간이 그대로 남는다).
96
+ * 그래서 이것은 **공개**다 — 호스트가 시각을 주며 부를 수 있다(`tick` 도 이것을 부른다).
97
+ */
98
+ closeDue(nowMs: number): DemandWindowState[];
99
+ /**
100
+ * 열린 구간의 판정 — **이대로 가면 계약을 넘는가.**
101
+ *
102
+ * 예측은 「지금까지의 평균 부하가 구간 끝까지 이어진다」다. 단순하지만 그 가정을 **값과 함께 낸다**
103
+ * (`projectionBasis`) — 근거를 감춘 예측은 사용자가 검증할 수 없다. 남은 시간이 짧을수록 이 예측은
104
+ * 실제에 가까워진다(구간 초반의 경보는 성급할 수 있다는 뜻이고, 그것도 사용자가 알아야 한다).
105
+ *
106
+ * 넘을 것 같으면 **제안**을 낸다 — 무엇을 줄일 수 있는지는 모델이 선언한 감축 가능 설비가 답한다.
107
+ * 우리는 끄지 않는다.
108
+ */
109
+ private judgeOpenWindow;
110
+ /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
111
+ private curtailableIds;
112
+ private flowRequests;
113
+ private noteFlowRequest;
114
+ protected onArrival(): void;
115
+ protected onOrder(): void;
116
+ protected allocate(): void;
117
+ protected onTaskComplete(): void;
118
+ /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
119
+ tick(dtMs: number): void;
120
+ getSnapshot(): StateSnapshot;
121
+ }