@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.
@@ -27,11 +27,18 @@ __export(index_exports, {
27
27
  CAPABILITY_KEYS: () => CAPABILITY_KEYS,
28
28
  CBV_BIZSTEP: () => CBV_BIZSTEP,
29
29
  CMD: () => CMD,
30
+ DEMAND_WINDOW_MS: () => DEMAND_WINDOW_MS,
30
31
  DISP: () => DISP,
31
32
  DOMAIN_CATALOG: () => DOMAIN_CATALOG,
32
33
  DOMAIN_SYSTEMS: () => DOMAIN_SYSTEMS,
34
+ EMS_EQUIPMENT_TYPES: () => EMS_EQUIPMENT_TYPES,
35
+ EMS_LOCATION_TYPES: () => EMS_LOCATION_TYPES,
36
+ EMS_PROPERTY: () => EMS_PROPERTY,
37
+ EMS_TYPES: () => EMS_TYPES,
38
+ ENERGY_EVENT: () => ENERGY_EVENT,
33
39
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
34
40
  EQUIPMENT_LEVEL: () => EQUIPMENT_LEVEL,
41
+ EmsKernel: () => EmsKernel,
35
42
  EventJournal: () => EventJournal,
36
43
  FlowEngine: () => FlowEngine,
37
44
  GUARD_PRAGMA: () => GUARD_PRAGMA,
@@ -70,6 +77,8 @@ __export(index_exports, {
70
77
  activeShiftOf: () => activeShiftOf,
71
78
  aggregationEvent: () => aggregationEvent,
72
79
  analyzeCapacity: () => analyzeCapacity,
80
+ axesOfSystem: () => axesOfSystem,
81
+ axisAppliesTo: () => axisAppliesTo,
73
82
  axisInfo: () => axisInfo,
74
83
  axisSource: () => axisSource,
75
84
  capabilitiesForType: () => capabilitiesForType,
@@ -80,6 +89,7 @@ __export(index_exports, {
80
89
  constantDuration: () => constantDuration,
81
90
  conversionFactorOf: () => conversionFactorOf,
82
91
  counterfactualAt: () => counterfactualAt,
92
+ demandWindowStart: () => demandWindowStart,
83
93
  deriveAttentions: () => deriveAttentions,
84
94
  documentPath: () => documentPath,
85
95
  dueStatusOf: () => dueStatusOf,
@@ -97,6 +107,7 @@ __export(index_exports, {
97
107
  isEquipmentLevel: () => isEquipmentLevel,
98
108
  isoDurationHours: () => isoDurationHours,
99
109
  itemKeyOf: () => itemKeyOf,
110
+ levelOfLocationType: () => levelOfLocationType,
100
111
  lgtinClass: () => lgtinClass,
101
112
  locationStatusOf: () => locationStatusOf,
102
113
  mapRecord: () => mapRecord,
@@ -167,7 +178,7 @@ var EQUIPMENT_LEVEL = [
167
178
  function isEquipmentLevel(v) {
168
179
  return typeof v === "string" && EQUIPMENT_LEVEL.includes(v);
169
180
  }
170
- function hierarchyOf(s) {
181
+ function hierarchyOf(s, levelOfType) {
171
182
  const push = (m, k, v) => {
172
183
  const cur = m.get(k);
173
184
  if (cur) cur.push(v);
@@ -210,7 +221,7 @@ function hierarchyOf(s) {
210
221
  return out;
211
222
  };
212
223
  const typeOf = new Map(s.locations.map((n) => [n.id, n.type]));
213
- const levelOf = new Map(s.locations.map((n) => [n.id, n.level]));
224
+ const levelOf = new Map(s.locations.map((n) => [n.id, n.level ?? (n.type ? levelOfType?.(n.type) : void 0)]));
214
225
  return {
215
226
  childrenOf: (id) => [...kids.get(id) ?? []],
216
227
  ancestorsOf,
@@ -455,7 +466,7 @@ var OP_EVENT = {
455
466
  *
456
467
  * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
457
468
  * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
458
- * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
469
+ * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
459
470
  */
460
471
  /*
461
472
  * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
@@ -464,6 +475,35 @@ var OP_EVENT = {
464
475
  */
465
476
  attentionAck: "attention.acked"
466
477
  };
478
+ var ENERGY_EVENT = {
479
+ /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
480
+ measured: "energy.measured",
481
+ /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
482
+ demandWindow: "energy.demand.window",
483
+ /**
484
+ * 피크 경신 — 월 최대 수요가 갱신됐다.
485
+ *
486
+ * 구간 마감에서 파생되지만 **사실로 남긴다**: 요금의 근거이고, 나중에 "언제 무엇 때문에 올랐나" 를
487
+ * 물을 때 파생으로는 답할 수 없다(그 순간의 부하 구성이 사라진다).
488
+ */
489
+ peak: "energy.peak",
490
+ /** 요금 구간 전환 — 경부하·중간부하·최대부하. 달력이 아니라 사건이다(계절제·특례로 바뀐다). */
491
+ tariffShift: "energy.tariff.shift",
492
+ /** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
493
+ generated: "energy.generated",
494
+ /** 저장(ESS 충전). */
495
+ stored: "energy.stored",
496
+ /** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
497
+ discharged: "energy.discharged",
498
+ /**
499
+ * 수요 제어 제안 — **제안이지 명령이 아니다.**
500
+ *
501
+ * 이 트윈은 차단·투입을 하지 않는다(안전 계통은 범위 밖: `ems.md` §1). 이대로면 계약을 넘는다는
502
+ * 판단과 무엇을 줄이면 되는지를 낼 뿐이고, 집행은 사람과 그 시스템의 몫이다. 이름을 `suggested`
503
+ * 로 둔 이유가 그것이다 — 저널만 보고도 "우리가 끈 것이 아니다" 를 알 수 있어야 한다.
504
+ */
505
+ drSuggested: "energy.dr.suggested"
506
+ };
467
507
  var CMD = {
468
508
  orderHold: "order.hold",
469
509
  orderResume: "order.resume",
@@ -978,11 +1018,13 @@ var ObservedReducer = class {
978
1018
  revision = 0;
979
1019
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
980
1020
  corrections = [];
1021
+ /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
1022
+ unhandled = /* @__PURE__ */ new Map();
981
1023
  /**
982
1024
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
983
1025
  *
984
1026
  * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
985
- * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
1027
+ * 어긋난다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
986
1028
  */
987
1029
  shifts = /* @__PURE__ */ new Map();
988
1030
  /**
@@ -994,25 +1036,25 @@ var ObservedReducer = class {
994
1036
  classDefs = {};
995
1037
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
996
1038
  utcOffsetMinutes;
997
- constructor(board) {
998
- this.utcOffsetMinutes = board.utcOffsetMinutes;
999
- this.classDefs = { personnel: board.personnelClasses, equipment: board.equipmentClasses, asset: board.assetClasses };
1000
- for (const n of readBoardLocations(board)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
1001
- for (const m of readBoardEquipment(board)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
1002
- for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
1003
- for (const m of readBoardEquipment(board)) this.rememberShift(`eq:${m.id}`, m);
1004
- for (const p of board.persons ?? []) this.rememberShift(`person:${p.id}`, p);
1005
- for (const a of readBoardAssets(board)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
1039
+ constructor(model) {
1040
+ this.utcOffsetMinutes = model.utcOffsetMinutes;
1041
+ this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
1042
+ for (const n of readBoardLocations(model)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
1043
+ for (const m of readBoardEquipment(model)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
1044
+ for (const p of model.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
1045
+ for (const m of readBoardEquipment(model)) this.rememberShift(`eq:${m.id}`, m);
1046
+ for (const p of model.persons ?? []) this.rememberShift(`person:${p.id}`, p);
1047
+ for (const a of readBoardAssets(model)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...a.testResults ? { testResults: a.testResults } : {}, ...effectiveOf(a) });
1006
1048
  }
1007
1049
  /**
1008
- * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
1050
+ * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
1009
1051
  *
1010
1052
  * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
1011
1053
  * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
1012
1054
  * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
1013
1055
  *
1014
1056
  * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
1015
- * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
1057
+ * 불러 전환한 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
1016
1058
  *
1017
1059
  * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
1018
1060
  * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
@@ -1022,9 +1064,9 @@ var ObservedReducer = class {
1022
1064
  * 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
1023
1065
  * 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
1024
1066
  */
1025
- adoptStructure(board) {
1026
- this.utcOffsetMinutes = board.utcOffsetMinutes;
1027
- const locations = readBoardLocations(board);
1067
+ adoptStructure(model) {
1068
+ this.utcOffsetMinutes = model.utcOffsetMinutes;
1069
+ const locations = readBoardLocations(model);
1028
1070
  const declaredLocationIds = new Set(locations.map((n) => n.id));
1029
1071
  let locationsAdded = 0;
1030
1072
  let locationsDropped = 0;
@@ -1037,9 +1079,9 @@ var ObservedReducer = class {
1037
1079
  if (!this.master.has(n.id)) locationsAdded++;
1038
1080
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
1039
1081
  }
1040
- const equipment = readBoardEquipment(board);
1041
- const assets = readBoardAssets(board);
1042
- const persons = board.persons ?? [];
1082
+ const equipment = readBoardEquipment(model);
1083
+ const assets = readBoardAssets(model);
1084
+ const persons = model.persons ?? [];
1043
1085
  const dropMissing = (map, declared) => {
1044
1086
  const ids = new Set(declared.map((d) => d.id));
1045
1087
  let dropped = 0;
@@ -1131,7 +1173,7 @@ var ObservedReducer = class {
1131
1173
  /**
1132
1174
  * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
1133
1175
  *
1134
- * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
1176
+ * 미러는 스스로 시간을 진행시키지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
1135
1177
  * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
1136
1178
  * 판정하지 않는다(모르면 단정하지 않는다).
1137
1179
  */
@@ -1248,7 +1290,20 @@ var ObservedReducer = class {
1248
1290
  });
1249
1291
  break;
1250
1292
  }
1293
+ default:
1294
+ this.noteUnhandled(e);
1295
+ }
1296
+ }
1297
+ /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
1298
+ noteUnhandled(e) {
1299
+ const at = Date.parse(String(e.eventTime ?? ""));
1300
+ const cur = this.unhandled.get(e.eventType) ?? { count: 0 };
1301
+ cur.count++;
1302
+ if (Number.isFinite(at)) {
1303
+ if (cur.firstAtMs === void 0 || at < cur.firstAtMs) cur.firstAtMs = at;
1304
+ if (cur.lastAtMs === void 0 || at > cur.lastAtMs) cur.lastAtMs = at;
1251
1305
  }
1306
+ this.unhandled.set(e.eventType, cur);
1252
1307
  }
1253
1308
  applyEpcis(ev, envelope) {
1254
1309
  if (ev.errorDeclaration) {
@@ -1351,7 +1406,7 @@ var ObservedReducer = class {
1351
1406
  *
1352
1407
  * **한 항목뿐이면 담지 않는다.** 그 하나는 `qty`/`uom` 이 이미 무손실로 들고 있고, 1개짜리
1353
1408
  * 배열을 더 두면 같은 사실이 두 곳에 생긴다(그리고 시뮬은 그 배열을 만들지 않아 두 구동이
1354
- * 갈라진다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
1409
+ * 어긋난다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
1355
1410
  * 읽을 때는 단위를 가리지 않는 `quantityIn` 을 쓴다(단일이든 복수든 같은 답).
1356
1411
  */
1357
1412
  quantities: (() => {
@@ -1439,6 +1494,7 @@ var ObservedReducer = class {
1439
1494
  return {
1440
1495
  revision: this.revision,
1441
1496
  ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
1497
+ ...this.unhandled.size ? { unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })).sort((a, b) => b.count - a.count) } : {},
1442
1498
  locations: [...this.master.values()].map((n) => {
1443
1499
  const occupancy = occ.get(n.id) ?? 0;
1444
1500
  const status = locationStatusOf({ occupancy, capacity: n.capacity });
@@ -1494,17 +1550,17 @@ var EventJournal = class {
1494
1550
  return this.events.filter((e) => e.eventTime <= iso2);
1495
1551
  }
1496
1552
  };
1497
- function replay(board, events) {
1498
- const proj = new ObservedReducer(board);
1553
+ function replay(model, events) {
1554
+ const proj = new ObservedReducer(model);
1499
1555
  for (const e of events) proj.apply(e);
1500
1556
  return proj.snapshot();
1501
1557
  }
1502
1558
  function replaySegments(segments) {
1503
1559
  if (!segments.length) throw new Error("\uC7AC\uC0DD\uD560 \uB9C8\uB514\uAC00 \uC5C6\uB2E4 \u2014 \uAD6C\uC870\uB97C \uD558\uB098\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4");
1504
- const proj = new ObservedReducer(segments[0].board);
1560
+ const proj = new ObservedReducer(segments[0].model);
1505
1561
  const shifts = [];
1506
1562
  for (let i = 0; i < segments.length; i++) {
1507
- if (i > 0) shifts.push({ index: i, ...proj.adoptStructure(segments[i].board) });
1563
+ if (i > 0) shifts.push({ index: i, ...proj.adoptStructure(segments[i].model) });
1508
1564
  for (const e of segments[i].events) proj.apply(e);
1509
1565
  }
1510
1566
  return { state: proj.snapshot(), shifts };
@@ -1525,14 +1581,126 @@ var BTT = {
1525
1581
  so: "urn:epcglobal:cbv:btt:so"
1526
1582
  };
1527
1583
  var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
1584
+ var WMS_LOCATION_LEVEL = {
1585
+ dock: "StorageZone",
1586
+ storage: "StorageUnit",
1587
+ staging: "StorageZone",
1588
+ "dock-ship": "StorageZone",
1589
+ "vas-station": "WorkCell"
1590
+ };
1528
1591
  var WMS_TYPES = [
1529
- ...WMS_LOCATION_TYPES.filter((k) => k !== "vas-station").map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1530
- { key: "vas-station", role: "location", label: "twin.type.vas-station", standardClass: { epcis: "bizLocation", isa95: "WorkCenter" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable", "processable"] },
1592
+ ...WMS_LOCATION_TYPES.filter((k) => k !== "vas-station").map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, level: WMS_LOCATION_LEVEL[k], capabilities: ["storable"] })),
1593
+ { key: "vas-station", role: "location", label: "twin.type.vas-station", standardClass: { epcis: "bizLocation", isa95: "WorkCenter" }, identity: { scheme: "gs1:SGLN" }, level: WMS_LOCATION_LEVEL["vas-station"], capabilities: ["storable", "processable"] },
1531
1594
  { key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] },
1532
1595
  /** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
1533
1596
  { key: "packer", role: "equipment", label: "twin.type.packer", standardClass: { epcis: "object", isa95: "Equipment" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1534
1597
  ];
1535
1598
 
1599
+ // src/ems-profile.ts
1600
+ var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
1601
+ var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "curtailable-load"];
1602
+ var EMS_PROPERTY = {
1603
+ /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
1604
+ contractKW: "contract.kW"
1605
+ };
1606
+ var EMS_TYPES = [
1607
+ /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
1608
+ {
1609
+ key: "incoming",
1610
+ role: "location",
1611
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1612
+ level: "Other",
1613
+ label: "twin.type.incoming",
1614
+ /* 수전 지점 — 계약전력이 걸리는 자리이고, 요금의 근거가 되는 수요는 여기서 잰다.
1615
+ ISO 50001 의 「에너지 유입」 경계이기도 하다(조직의 에너지 검토가 여기서 시작한다). */
1616
+ standardClass: { iec61850: "MMTR", iso50001: "EnergyInput" },
1617
+ identity: { scheme: "kernel:id" },
1618
+ capabilities: ["metered"]
1619
+ },
1620
+ {
1621
+ key: "feeder",
1622
+ role: "location",
1623
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1624
+ level: "Other",
1625
+ label: "twin.type.feeder",
1626
+ /* 분기 회로 — 부하 분해의 단위. 계약전력의 하위 배분이 여기서 정해진다. */
1627
+ standardClass: { iec61850: "Feeder", iso50001: "EnergyUse" },
1628
+ identity: { scheme: "kernel:id" },
1629
+ capabilities: ["metered"]
1630
+ },
1631
+ {
1632
+ key: "submeter-zone",
1633
+ role: "location",
1634
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1635
+ level: "Other",
1636
+ label: "twin.type.submeter-zone",
1637
+ /*
1638
+ * 구역 계량 — 여러 부하를 한 계량기로 묶어 재는 자리(공조·조명처럼 개별 계량이 없는 것들).
1639
+ *
1640
+ * ISO 50001 의 **SEU**(유의 에너지 사용처)가 대개 이 알갱이다. 개별 설비까지 재지 못하는 현장이
1641
+ * 많고, 그것을 「모른다」로 두는 대신 **묶음으로 아는 것**이 정직하다.
1642
+ */
1643
+ standardClass: { iec61850: "MMXU", iso50001: "SEU" },
1644
+ identity: { scheme: "kernel:id" },
1645
+ capabilities: ["metered"]
1646
+ },
1647
+ /* ── 설비: 계량 지점과 에너지 자원 ──────────────────────────────────────── */
1648
+ {
1649
+ key: "meter",
1650
+ role: "equipment",
1651
+ label: "twin.type.meter",
1652
+ /* 계량기 — 측정 논리 노드(MMXU=측정단위, MMTR=적산). 자산으로도 하나다(ISO 55000). */
1653
+ standardClass: { iec61850: "MMXU", iso55000: "Asset", iso50001: "MeasurementPoint" },
1654
+ identity: { scheme: "kernel:id" },
1655
+ capabilities: ["metered", "operable"]
1656
+ },
1657
+ {
1658
+ key: "breaker",
1659
+ role: "equipment",
1660
+ label: "twin.type.breaker",
1661
+ /*
1662
+ * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
1663
+ * 상태를 읽어 계통 구성을 알 뿐이다 — 그래서 능력은 `operable` 만이고 `curtailable` 이 아니다.
1664
+ */
1665
+ standardClass: { iec61850: "XCBR", iso55000: "Asset" },
1666
+ identity: { scheme: "kernel:id" },
1667
+ capabilities: ["operable"]
1668
+ },
1669
+ {
1670
+ key: "pv-array",
1671
+ role: "equipment",
1672
+ label: "twin.type.pv-array",
1673
+ /* 태양광 어레이 — IEC 61850-7-420(분산자원)의 `DPVA`. 발전과 계량은 다른 능력이다. */
1674
+ standardClass: { iec61850: "DPVA", iso55000: "Asset", iso50001: "RenewableSupply" },
1675
+ identity: { scheme: "kernel:id" },
1676
+ capabilities: ["generating", "metered", "operable"]
1677
+ },
1678
+ {
1679
+ key: "battery",
1680
+ role: "equipment",
1681
+ label: "twin.type.battery",
1682
+ /* 축전지(ESS) — `ZBAT`. 충전·방전을 나눠 재고, 저장은 보관(`storable`)이 아니다(물건이 아니다). */
1683
+ standardClass: { iec61850: "ZBAT", iso55000: "Asset" },
1684
+ identity: { scheme: "kernel:id" },
1685
+ capabilities: ["storing", "metered", "operable"]
1686
+ },
1687
+ {
1688
+ key: "curtailable-load",
1689
+ role: "equipment",
1690
+ label: "twin.type.curtailable-load",
1691
+ /*
1692
+ * 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
1693
+ *
1694
+ * 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
1695
+ * 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
1696
+ * 적합성 표가 거짓을 말한다.
1697
+ */
1698
+ standardClass: { iso50001: "SEU" },
1699
+ identity: { scheme: "kernel:id" },
1700
+ capabilities: ["curtailable", "metered", "operable"]
1701
+ }
1702
+ ];
1703
+
1536
1704
  // src/capability.ts
1537
1705
  var CAPABILITIES = {
1538
1706
  operable: {
@@ -1571,9 +1739,49 @@ var CAPABILITIES = {
1571
1739
  semantics: "\uC624\uB354/\uC544\uC774\uD15C \uC0DD\uC560 \uCD94\uC801 \u2014 \uC0DD\uC560\uB2E8\uACC4(\uB3C4\uBA54\uC778 \uB77C\uBCA8, \uBB34\uBC29\uC5B8)\xB7\uC9C4\uD589\xB7\uBCF4\uB958.",
1572
1740
  stateFields: ["lifecycle", "progress", "held"],
1573
1741
  results: ["lifecycleChanged"]
1742
+ },
1743
+ /*
1744
+ * ── 에너지 능력 넷 ──────────────────────────────────────────────────────
1745
+ * 상태 필드는 기존 능력과 **직교**한다(원칙 ②) — 어느 필드도 두 능력에 겹치지 않는다.
1746
+ * 계량은 `operable`(가동 상태)과도 직교한다: 도는 것과 재는 것은 다른 사실이고, 멈춘 설비도
1747
+ * 대기전력을 쓴다.
1748
+ */
1749
+ metered: {
1750
+ key: "metered",
1751
+ label: "twin.capability.metered",
1752
+ semantics: "\uC790\uAE30 \uC18C\uBE44\uB97C \uC7B0\uB2E4 \u2014 \uC720\uD6A8\uC804\uB825\xB7\uB204\uC801\uB7C9\xB7\uC5ED\uB960. \uAC12\uC774 \uC5C6\uC73C\uBA74 \uBAA8\uB974\uB294 \uAC83\uC774\uB2E4(0 \uC774 \uC544\uB2C8\uB2E4).",
1753
+ stateFields: ["kW", "kWh", "powerFactor"],
1754
+ models: ["MeteredState"],
1755
+ results: ["measured"]
1756
+ },
1757
+ curtailable: {
1758
+ key: "curtailable",
1759
+ label: "twin.capability.curtailable",
1760
+ semantics: "\uC904\uC77C \uC218 \uC788\uB2E4 \u2014 \uCD5C\uC18C \uC720\uC9C0 \uC544\uB798\uB85C\uB294 \uB0B4\uB9AC\uC9C0 \uC54A\uB294\uB2E4. \uD310\uC815\uB9CC \uD558\uACE0 \uC9D1\uD589\uC740 \uC0AC\uB78C\uACFC \uADF8 \uC2DC\uC2A4\uD15C\uC758 \uBAAB\uC774\uB2E4.",
1761
+ stateFields: ["curtailable", "minKW"],
1762
+ models: ["CurtailableState"],
1763
+ invariants: ["minKW >= 0"],
1764
+ results: ["drSuggested"]
1765
+ },
1766
+ generating: {
1767
+ key: "generating",
1768
+ label: "twin.capability.generating",
1769
+ semantics: "\uC804\uAE30\uB97C \uB9CC\uB4E0\uB2E4 \u2014 \uC5ED\uC1A1\uC740 \uC18C\uBE44\uC758 \uC74C\uC218\uAC00 \uC544\uB2C8\uB77C \uBCC4\uAC1C \uC0AC\uC2E4\uC774\uB2E4.",
1770
+ stateFields: ["generatedKW", "exportKW"],
1771
+ models: ["GeneratingState"],
1772
+ results: ["generated"]
1773
+ },
1774
+ storing: {
1775
+ key: "storing",
1776
+ label: "twin.capability.storing",
1777
+ semantics: "\uB2F4\uACE0 \uB0B8\uB2E4 \u2014 \uCDA9\uC804\uACFC \uBC29\uC804\uC744 \uB098\uB208\uB2E4(\uC190\uC2E4\xB7\uC218\uBA85 \uD310\uB2E8\uC774 \uB458\uC744 \uAD6C\uBCC4\uD574\uC57C \uD55C\uB2E4).",
1778
+ stateFields: ["soc", "chargeKW", "dischargeKW"],
1779
+ models: ["StoringState"],
1780
+ invariants: ["0 <= soc <= 100"],
1781
+ results: ["stored", "discharged"]
1574
1782
  }
1575
1783
  };
1576
- var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable"];
1784
+ var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "metered", "curtailable", "generating", "storing"];
1577
1785
  function stateFieldsOf(caps) {
1578
1786
  const out = /* @__PURE__ */ new Set();
1579
1787
  for (const c of caps) for (const f of CAPABILITIES[c]?.stateFields ?? []) out.add(f);
@@ -1598,8 +1806,14 @@ function graiUri(companyPrefix, assetType, serial) {
1598
1806
  return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
1599
1807
  }
1600
1808
  var YMS_LOCATION_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
1809
+ var YMS_LOCATION_LEVEL = {
1810
+ gate: "Other",
1811
+ "yard-slot": "StorageUnit",
1812
+ "dock-door": "StorageUnit",
1813
+ staging: "StorageZone"
1814
+ };
1601
1815
  var YMS_TYPES = [
1602
- ...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1816
+ ...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, level: YMS_LOCATION_LEVEL[k], capabilities: ["storable"] })),
1603
1817
  { key: "hostler", role: "equipment", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
1604
1818
  ];
1605
1819
 
@@ -1625,8 +1839,16 @@ var MES_LOCATION_CLS = {
1625
1839
  "assembly-line": { isa95: "WorkCenter", epcis: "bizLocation" },
1626
1840
  "fg-store": { epcis: "bizLocation" }
1627
1841
  };
1842
+ var MES_LOCATION_LEVEL = {
1843
+ "raw-store": "StorageZone",
1844
+ "cut-station": "WorkCell",
1845
+ "weld-station": "WorkCell",
1846
+ "paint-booth": "WorkCell",
1847
+ "assembly-line": "ProductionLine",
1848
+ "fg-store": "StorageZone"
1849
+ };
1628
1850
  var MES_TYPES = [
1629
- ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1851
+ ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, level: MES_LOCATION_LEVEL[k], capabilities: ["storable"] })),
1630
1852
  { key: "cutter", role: "equipment", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1631
1853
  { key: "welder", role: "equipment", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1632
1854
  { key: "painter", role: "equipment", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
@@ -1639,8 +1861,10 @@ var DOMAIN_CATALOG = {
1639
1861
  // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
1640
1862
  wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
1641
1863
  yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
1642
- mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) }
1864
+ mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) },
1865
+ ems: { system: "ems", label: "twin.system.ems", types: EMS_TYPES, locationTypes: locationKeys(EMS_TYPES) }
1643
1866
  };
1867
+ var LOGISTICS = ["wms", "yms", "mes"];
1644
1868
  var TWIN_AXES = [
1645
1869
  {
1646
1870
  axis: "locations",
@@ -1663,7 +1887,8 @@ var TWIN_AXES = [
1663
1887
  path: "persons",
1664
1888
  label: "twin.axis.persons",
1665
1889
  kind: "instance",
1666
- standardClass: { isa95: "Person" }
1890
+ standardClass: { isa95: "Person" },
1891
+ systems: LOGISTICS
1667
1892
  },
1668
1893
  {
1669
1894
  axis: "assets",
@@ -1677,7 +1902,8 @@ var TWIN_AXES = [
1677
1902
  path: "personnelClasses",
1678
1903
  label: "twin.axis.personnelClasses",
1679
1904
  kind: "class",
1680
- standardClass: { isa95: "PersonnelClass" }
1905
+ standardClass: { isa95: "PersonnelClass" },
1906
+ systems: LOGISTICS
1681
1907
  },
1682
1908
  {
1683
1909
  axis: "equipmentClasses",
@@ -1698,28 +1924,32 @@ var TWIN_AXES = [
1698
1924
  path: "materialDefinitions",
1699
1925
  label: "twin.axis.materialDefinitions",
1700
1926
  kind: "class",
1701
- standardClass: { isa95: "MaterialDefinition" }
1927
+ standardClass: { isa95: "MaterialDefinition" },
1928
+ systems: LOGISTICS
1702
1929
  },
1703
1930
  {
1704
1931
  axis: "materialClasses",
1705
1932
  path: "materialClasses",
1706
1933
  label: "twin.axis.materialClasses",
1707
1934
  kind: "class",
1708
- standardClass: { isa95: "MaterialClass" }
1935
+ standardClass: { isa95: "MaterialClass" },
1936
+ systems: LOGISTICS
1709
1937
  },
1710
1938
  {
1711
1939
  axis: "operations",
1712
1940
  path: "operations",
1713
1941
  label: "twin.axis.operations",
1714
1942
  kind: "spec",
1715
- standardClass: { isa95: "OperationsSegment" }
1943
+ standardClass: { isa95: "OperationsSegment" },
1944
+ systems: LOGISTICS
1716
1945
  },
1717
1946
  {
1718
1947
  axis: "productionSpec",
1719
1948
  path: "productionSpec",
1720
1949
  label: "twin.axis.productionSpec",
1721
1950
  kind: "spec",
1722
- standardClass: { isa95: "OperationsSegment" }
1951
+ standardClass: { isa95: "OperationsSegment" },
1952
+ systems: LOGISTICS
1723
1953
  },
1724
1954
  /*
1725
1955
  * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
@@ -1732,21 +1962,24 @@ var TWIN_AXES = [
1732
1962
  path: "productionSpec.definition.recipes",
1733
1963
  label: "twin.axis.recipes",
1734
1964
  kind: "spec",
1735
- standardClass: { isa95: "OperationsSegment" }
1965
+ standardClass: { isa95: "OperationsSegment" },
1966
+ systems: LOGISTICS
1736
1967
  },
1737
1968
  {
1738
1969
  axis: "routes",
1739
1970
  path: "productionSpec.definition.routes",
1740
1971
  label: "twin.axis.routes",
1741
1972
  kind: "spec",
1742
- standardClass: { isa95: "OperationsSegment" }
1973
+ standardClass: { isa95: "OperationsSegment" },
1974
+ systems: LOGISTICS
1743
1975
  },
1744
1976
  {
1745
1977
  axis: "materials",
1746
1978
  path: "productionSpec.definition.materials",
1747
1979
  label: "twin.axis.materials",
1748
1980
  kind: "class",
1749
- standardClass: { isa95: "MaterialDefinition" }
1981
+ standardClass: { isa95: "MaterialDefinition" },
1982
+ systems: LOGISTICS
1750
1983
  },
1751
1984
  /*
1752
1985
  * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
@@ -1786,7 +2019,8 @@ var TWIN_AXES = [
1786
2019
  kind: "instance",
1787
2020
  source: "state",
1788
2021
  historical: true,
1789
- standardClass: { isa95: "OperationsRequest", epcis: "TransactionEvent" }
2022
+ standardClass: { isa95: "OperationsRequest", epcis: "TransactionEvent" },
2023
+ systems: LOGISTICS
1790
2024
  },
1791
2025
  {
1792
2026
  axis: "tasks",
@@ -1794,7 +2028,8 @@ var TWIN_AXES = [
1794
2028
  kind: "instance",
1795
2029
  source: "state",
1796
2030
  historical: true,
1797
- standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" }
2031
+ standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
2032
+ systems: LOGISTICS
1798
2033
  }
1799
2034
  ];
1800
2035
  var TWIN_RELATIONS = [
@@ -1816,7 +2051,7 @@ var TWIN_RELATIONS = [
1816
2051
  { from: "routes", field: "steps[]", target: { kind: "axis", axis: "operations" }, via: "twin.rel.step" },
1817
2052
  /*
1818
2053
  * 일정·실적의 관계 — **관측 관계다.** 선언 관계는 "그렇게 만들기로 했다" 이고, 이것은 "그렇게
1819
- * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 있는 카드가 된다(그러면 걸어 들어갈 수 없다).
2054
+ * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 연결되지 않은 카드가 된다(그러면 걸어 들어갈 수 없다).
1820
2055
  */
1821
2056
  /*
1822
2057
  * 오더가 무엇을 만드나 — **자재 키가 아니라 GS1 품목 참조다**(`lines[].gtin`). 자재와 이어 주는
@@ -1912,7 +2147,20 @@ var relationsTo = (axis) => TWIN_RELATIONS.filter((r) => r.target.kind === "axis
1912
2147
  function axisInfo(axis) {
1913
2148
  return TWIN_AXES.find((a) => a.axis === axis);
1914
2149
  }
1915
- var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
2150
+ var DOMAIN_SYSTEMS = ["wms", "yms", "mes", "ems"];
2151
+ var axisAppliesTo = (axis, system) => {
2152
+ const declared = TWIN_AXES.find((a) => a.axis === axis)?.systems;
2153
+ if (!declared || !system) return true;
2154
+ if (!DOMAIN_SYSTEMS.includes(system)) return true;
2155
+ return declared.includes(system);
2156
+ };
2157
+ var levelOfLocationType = (typeKey, system) => {
2158
+ const pick = (sys) => DOMAIN_CATALOG[sys]?.types.find((t) => t.role === "location" && t.key === typeKey)?.level;
2159
+ if (system && DOMAIN_SYSTEMS.includes(system)) return pick(system);
2160
+ const found = [...new Set(DOMAIN_SYSTEMS.map(pick).filter(Boolean))];
2161
+ return found.length === 1 ? found[0] : void 0;
2162
+ };
2163
+ var axesOfSystem = (system) => TWIN_AXES.filter((a) => axisAppliesTo(a.axis, system));
1916
2164
  function capabilitiesForType(system, typeKey) {
1917
2165
  return DOMAIN_CATALOG[system]?.types.find((t) => t.key === typeKey)?.capabilities ?? [];
1918
2166
  }
@@ -2521,7 +2769,7 @@ var FlowEngine = class {
2521
2769
  /*
2522
2770
  * ── 선언 하나를 상태 하나로 짓는 자리 ─────────────────────────────────────
2523
2771
  * 최초 적재(`loadTwinModel`)와 가동 중 교체(`adoptStructure`)가 **같은 구성을 쓴다.** 두 곳에서
2524
- * 따로 지으면 곧 갈라지고, 갈라진 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2772
+ * 따로 지으면 곧 갈라지고, 어긋난 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2525
2773
  */
2526
2774
  buildLocation(n) {
2527
2775
  return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId };
@@ -2565,7 +2813,7 @@ var FlowEngine = class {
2565
2813
  this.observeMode = true;
2566
2814
  }
2567
2815
  /**
2568
- * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2816
+ * 돌면서 공장을 전환한다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2569
2817
  *
2570
2818
  * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
2571
2819
  * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
@@ -2578,7 +2826,7 @@ var FlowEngine = class {
2578
2826
  * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
2579
2827
  * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
2580
2828
  *
2581
- * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
2829
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 주입한다 — 다음 스냅샷에서 관측층이
2582
2830
  * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
2583
2831
  */
2584
2832
  adoptStructure(def) {
@@ -2670,7 +2918,7 @@ var FlowEngine = class {
2670
2918
  this.items.clear();
2671
2919
  for (const it of snap.items) {
2672
2920
  this.items.set(itemKeyOf(it), {
2673
- /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
2921
+ /* 품번 키·로트는 식별자에서 파생되므로 주입하지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
2674
2922
  epc: it.epc,
2675
2923
  location: it.location,
2676
2924
  disposition: it.disposition ?? DISP.sellable,
@@ -2706,13 +2954,13 @@ var FlowEngine = class {
2706
2954
  * **잰 값을 이어받으면 잰 구간도 이어받아야 한다.**
2707
2955
  *
2708
2956
  * 카운터만 되살리고 창을 지금으로 두면 둘이 어긋난다: 이어받은 가동 8시간이 방금 시작한 창
2709
- * 안에 앉아, 놀고 있는 설비가 **가동률 100%·유휴 0초**로 보인다(화면에서 관측됨).
2957
+ * 안에 앉아, 대기 중인 설비가 **가동률 100%·유휴 0초**로 보인다(화면에서 관측됨).
2710
2958
  *
2711
2959
  * 창은 지표 자신이 말해 준다 — 계획 시간은 가동·준비·고장·유휴의 합이다. 그래서 그 합만큼
2712
2960
  * 뒤로 물려 창을 다시 세운다. 지표가 없으면 창도 없다(그때는 지금부터 새로 잰다).
2713
2961
  */
2714
2962
  ...oee ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.idleMs ?? 0)) } : {},
2715
- /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 굴린다
2963
+ /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
2716
2964
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
2717
2965
  ...m.held ? { held: true } : {},
2718
2966
  /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
@@ -3070,7 +3318,7 @@ var FlowEngine = class {
3070
3318
  return st;
3071
3319
  }),
3072
3320
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
3073
- * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
3321
+ * 다른 커널을 주입하면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
3074
3322
  * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
3075
3323
  tasks: [...this.tasks.values()].map((t) => ({
3076
3324
  id: t.id,
@@ -3154,7 +3402,7 @@ var FlowEngine = class {
3154
3402
  }
3155
3403
  /**
3156
3404
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
3157
- * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 굴려 forecast·발산(predicted vs actual) 검사에 쓴다.
3405
+ * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
3158
3406
  * fork 는 자기 구독자·시나리오를 갖고 원본과 격리(handlers·gens 비움, generating=false).
3159
3407
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
3160
3408
  */
@@ -3180,7 +3428,7 @@ var FlowEngine = class {
3180
3428
  * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
3181
3429
  *
3182
3430
  * ── 관측 모드에서 시계가 멈춰 있었다 ────────────────────────────────────
3183
- * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 굴리지 않는다). 그래서 라이브 트윈의 "지금" 이
3431
+ * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 진행시키지 않는다). 그래서 라이브 트윈의 "지금" 이
3184
3432
  * **BASE_EPOCH(2026-01-01)에 얼어 있었다.** 실 이벤트는 실제 시각을 달고 오므로 결과가 이렇게 된다:
3185
3433
  * · **납기 초과를 영원히 보고하지 않는다** — 실 납기가 항상 미래로 보인다
3186
3434
  * · 폐기·도입예정 판정이 틀린다(§EffectivePeriod)
@@ -3217,14 +3465,14 @@ var FlowEngine = class {
3217
3465
  return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
3218
3466
  }
3219
3467
  /**
3220
- * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
3468
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 실행한다.**
3221
3469
  *
3222
3470
  * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
3223
- * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
3471
+ * 어긋난다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
3224
3472
  * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
3225
3473
  *
3226
3474
  * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
3227
- * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
3475
+ * (`hydrateObserved`)로 커널 상태에 주입한다. 그래서 관측으로 실행한 커널을 그대로 `fork`·`tick` 할 수
3228
3476
  * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
3229
3477
  *
3230
3478
  * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
@@ -3280,14 +3528,14 @@ var FlowEngine = class {
3280
3528
  return void 0;
3281
3529
  }
3282
3530
  /**
3283
- * **이 공장이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
3531
+ * **이 공장이 하루 몇 대를 낼 수 있는가** — 실행해 보지 않고 답한다.
3284
3532
  *
3285
3533
  * 커널이 직접 답하는 이유: 필요한 사실이 전부 여기 있다(공정 명세·설비와 신뢰도·인원·물리자산·
3286
3534
  * 자리·근무 달력·시각 기준). 밖에서 모으면 그 값을 옮겨 적게 되고, 한쪽만 바뀌는 순간 "충분하다"
3287
3535
  * 가 조용히 거짓이 된다. 계산 자체는 순수 함수(`analyzeCapacity`)에 맡긴다.
3288
3536
  *
3289
3537
  * 달력은 **자원이 선언한 것**을 쓴다. 자원마다 다른 달력을 쓰는 현장이면 대표를 고를 수 없으므로
3290
- * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 천장이 틀린 채로
3538
+ * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 상한이 틀린 채로
3291
3539
  * 그럴듯해 보인다.
3292
3540
  */
3293
3541
  capacity(opts) {
@@ -3375,7 +3623,7 @@ var FlowEngine = class {
3375
3623
  /**
3376
3624
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
3377
3625
  *
3378
- * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 상수를 이긴다.
3626
+ * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 고정한 상수를 이긴다.
3379
3627
  * 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
3380
3628
  * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
3381
3629
  */
@@ -3458,7 +3706,7 @@ var FlowEngine = class {
3458
3706
  else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
3459
3707
  }
3460
3708
  /**
3461
- * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 상수인가.**
3709
+ * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
3462
3710
  *
3463
3711
  * 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
3464
3712
  * "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
@@ -3554,7 +3802,7 @@ var FlowEngine = class {
3554
3802
  this.observeDisposition(epcs, DISP.reserved, bizStep);
3555
3803
  }
3556
3804
  /**
3557
- * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
3805
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 어긋난다.
3558
3806
  *
3559
3807
  * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
3560
3808
  * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
@@ -3808,7 +4056,7 @@ var FlowEngine = class {
3808
4056
  }
3809
4057
  return picked;
3810
4058
  }
3811
- /** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
4059
+ /** 확보한 자산을 작업에 배정한다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
3812
4060
  assignAssets(t, gear) {
3813
4061
  if (!gear.length) return;
3814
4062
  t.assets = gear;
@@ -3946,7 +4194,7 @@ var FlowEngine = class {
3946
4194
  * 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
3947
4195
  *
3948
4196
  * 작업은 혼자 서지 못한다: 옮길 **물품**과, (있다면) 그것을 시킨 **오더** 위에 선다. 진행 중에
3949
- * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 심지 않아서.
4197
+ * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 주입하지 않아서.
3950
4198
  *
3951
4199
  * 그때 도메인 훅은 없는 것을 딛으려다 던진다(`order.gtin` · `item.location`). 그 예외 하나가
3952
4200
  * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
@@ -4056,7 +4304,7 @@ var FlowEngine = class {
4056
4304
  * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
4057
4305
  *
4058
4306
  * 배정도 스냅샷도 이 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
4059
- * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
4307
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(대기 중인 것은 맞지만 쓸 수 있는 것은 아니다).
4060
4308
  * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
4061
4309
  */
4062
4310
  capabilityOfResource(r, directClassIds, defs, at = this.now()) {
@@ -4121,7 +4369,7 @@ var FlowEngine = class {
4121
4369
  }
4122
4370
  return picked.length ? picked : null;
4123
4371
  }
4124
- /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
4372
+ /** 확보한 사람을 작업에 배정한다(설비까지 확정된 뒤). */
4125
4373
  assignCrew(t, crew) {
4126
4374
  if (!crew.length) return;
4127
4375
  t.personnel = crew;
@@ -4172,7 +4420,7 @@ var FlowEngine = class {
4172
4420
  * 유효 기간 밖인가 — **네 번째 이유**(§Effectivity). 설비·사람·자산이 같은 규칙을 쓴다.
4173
4421
  *
4174
4422
  * 시뮬 시각을 ISO 로 풀어 계약의 `effectivityAt` 에 넘긴다. **판정은 커널에 두지 않는다** —
4175
- * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 갈라진다.
4423
+ * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 어긋난다.
4176
4424
  */
4177
4425
  effectivityOf(r) {
4178
4426
  if (!r.effectiveStart && !r.effectiveEnd) return void 0;
@@ -4193,7 +4441,7 @@ var FlowEngine = class {
4193
4441
  /**
4194
4442
  * 스냅샷·델타에 실을 조각 — **선언한 기간과 판정을 함께** 낸다.
4195
4443
  *
4196
- * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 갈라진다.
4444
+ * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 어긋난다.
4197
4445
  * 선언이 없으면 아무것도 붙이지 않는다(대부분의 자원이 그렇다 — 필드를 늘리지 않는다).
4198
4446
  */
4199
4447
  effectivePart(r) {
@@ -4209,7 +4457,7 @@ var FlowEngine = class {
4209
4457
  * 지금 근무 시간 밖인가 — **캘린더가 있으면 그것으로, 없으면 옛 `window` 로** 판정한다.
4210
4458
  *
4211
4459
  * `window`(시 단위 하나)는 캘린더의 특수한 경우다. 둘을 한 함수로 모아 두면 인원·설비가 같은 규칙을
4212
- * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 갈라진다).
4460
+ * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 어긋난다).
4213
4461
  */
4214
4462
  offCalendar(r) {
4215
4463
  return offCalendarAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
@@ -4680,7 +4928,7 @@ var WmsKernel = class extends FlowEngine {
4680
4928
  * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
4681
4929
  * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
4682
4930
  *
4683
- * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
4931
+ * **한 오더에 사슬 하나만** 실행한다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
4684
4932
  * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
4685
4933
  */
4686
4934
  makeShortLines(o) {
@@ -4701,7 +4949,7 @@ var WmsKernel = class extends FlowEngine {
4701
4949
  if (plan.steps.length) return;
4702
4950
  }
4703
4951
  }
4704
- /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
4952
+ /** 이 오더가 실행 중인 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
4705
4953
  hasOpenMakeChain(orderId) {
4706
4954
  for (const t of this.tasks.values()) {
4707
4955
  if (t.orderId !== orderId || t.status === "completed") continue;
@@ -4748,7 +4996,7 @@ var WmsKernel = class extends FlowEngine {
4748
4996
  * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
4749
4997
  *
4750
4998
  * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
4751
- * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
4999
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 고정되어 있다(`품목@자리`). 창고의 출고 경로는
4752
5000
  * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
4753
5001
  * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
4754
5002
  * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
@@ -5020,7 +5268,7 @@ var MesKernel = class extends FlowEngine {
5020
5268
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
5021
5269
  *
5022
5270
  * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
5023
- * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
5271
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 구분한다) · 생산오더 연결. 코어의 일반
5024
5272
  * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
5025
5273
  *
5026
5274
  * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
@@ -5114,7 +5362,7 @@ var MesKernel = class extends FlowEngine {
5114
5362
  /**
5115
5363
  * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
5116
5364
  *
5117
- * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
5365
+ * 씨앗이 이미 이행된 오더를 주입하지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
5118
5366
  * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
5119
5367
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
5120
5368
  */
@@ -5270,6 +5518,221 @@ var MesKernel = class extends FlowEngine {
5270
5518
  }
5271
5519
  };
5272
5520
 
5521
+ // src/ems-kernel.ts
5522
+ var DEMAND_WINDOW_MS = 15 * 60 * 1e3;
5523
+ var demandWindowStart = (atMs, windowMs = DEMAND_WINDOW_MS) => Math.floor(atMs / windowMs) * windowMs;
5524
+ var KEEP_CLOSED = 96;
5525
+ var EmsKernel = class extends FlowEngine {
5526
+ points = /* @__PURE__ */ new Map();
5527
+ open;
5528
+ closed = [];
5529
+ closedTotal = 0;
5530
+ peak;
5531
+ /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
5532
+ suggestedFor;
5533
+ windowMs;
5534
+ constructor(tenantId, policy = firstFitPolicy, windowMs = DEMAND_WINDOW_MS) {
5535
+ super(tenantId, policy);
5536
+ this.windowMs = windowMs;
5537
+ }
5538
+ /**
5539
+ * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
5540
+ *
5541
+ * 여러 자리가 선언하면 **가장 큰 값**을 쓴다: 수전 지점의 계약이 분기의 것보다 크고, 트윈 전체의
5542
+ * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
5543
+ * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
5544
+ */
5545
+ declaredContractKW() {
5546
+ let max;
5547
+ for (const loc of this.boardDef?.locations ?? []) {
5548
+ for (const p of loc.properties ?? []) {
5549
+ if (p.id !== EMS_PROPERTY.contractKW) continue;
5550
+ const v = Number(p.value);
5551
+ if (!Number.isFinite(v) || v <= 0) continue;
5552
+ if (max === void 0 || v > max) max = v;
5553
+ }
5554
+ }
5555
+ return max;
5556
+ }
5557
+ /**
5558
+ * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
5559
+ *
5560
+ * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
5561
+ * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
5562
+ */
5563
+ apply(envelope) {
5564
+ if (envelope.eventType === ENERGY_EVENT.measured) {
5565
+ this.ingestMeasured(envelope);
5566
+ return;
5567
+ }
5568
+ super.apply(envelope);
5569
+ }
5570
+ ingestMeasured(envelope) {
5571
+ const d = envelope.data;
5572
+ const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
5573
+ if (atMs === void 0) throw new Error("energy.measured has no usable time (data.at / eventTime) \u2014 cannot place it in a demand window");
5574
+ const id = String(d?.meterId ?? "").trim();
5575
+ if (!id) throw new Error("energy.measured has no meterId \u2014 a measurement with no meter cannot be accumulated");
5576
+ this.closeDue(atMs);
5577
+ this.openWindow(atMs);
5578
+ const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
5579
+ const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
5580
+ if (point.atMs === void 0 || atMs >= point.atMs) {
5581
+ point.atMs = atMs;
5582
+ if (kW !== void 0) point.kW = kW;
5583
+ if (Number.isFinite(Number(d?.kWh))) point.kWh = Number(d.kWh);
5584
+ if (Number.isFinite(Number(d?.powerFactor))) point.powerFactor = Number(d.powerFactor);
5585
+ }
5586
+ point.samplesInWindow++;
5587
+ this.points.set(id, point);
5588
+ const w = this.open;
5589
+ if (kW !== void 0) {
5590
+ const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
5591
+ if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
5592
+ w.samples++;
5593
+ w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
5594
+ }
5595
+ this.revision++;
5596
+ this.judgeOpenWindow(atMs);
5597
+ }
5598
+ openWindow(atMs) {
5599
+ const start = demandWindowStart(atMs, this.windowMs);
5600
+ if (this.open?.startMs === start) return;
5601
+ const contractKW = this.declaredContractKW();
5602
+ this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...contractKW !== void 0 ? { contractKW } : {} };
5603
+ for (const p of this.points.values()) p.samplesInWindow = 0;
5604
+ this.suggestedFor = void 0;
5605
+ }
5606
+ /**
5607
+ * 지난 구간을 닫는다 — **표본이 없으면 값을 만들지 않는다.**
5608
+ *
5609
+ * 침묵한 구간을 「0 kW」로 닫으면 그 트윈은 「그 15분 동안 전기를 쓰지 않았다」고 말하는 것이 된다.
5610
+ * 구간이 지난 것은 사실이고 우리가 못 쟀다는 것도 사실이므로, 구간은 남기고 값은 비운다.
5611
+ *
5612
+ * 표본이 오지 않으면 마감도 오지 않는다(라이브에서 계측이 끊기면 열린 구간이 그대로 남는다).
5613
+ * 그래서 이것은 **공개**다 — 호스트가 시각을 주며 부를 수 있다(`tick` 도 이것을 부른다).
5614
+ */
5615
+ closeDue(nowMs) {
5616
+ const done = [];
5617
+ while (this.open && nowMs >= this.open.endMs) {
5618
+ const w = this.open;
5619
+ if (w.contractKW !== void 0 && w.maxKW !== void 0) w.overContract = w.maxKW > w.contractKW;
5620
+ this.closed.push(w);
5621
+ this.closedTotal++;
5622
+ if (this.closed.length > KEEP_CLOSED) this.closed.splice(0, this.closed.length - KEEP_CLOSED);
5623
+ done.push(w);
5624
+ this.emitOp(ENERGY_EVENT.demandWindow, {
5625
+ startMs: w.startMs,
5626
+ endMs: w.endMs,
5627
+ ...w.maxKW !== void 0 ? { maxKW: w.maxKW } : {},
5628
+ ...w.meanKW !== void 0 ? { meanKW: w.meanKW } : {},
5629
+ samples: w.samples,
5630
+ ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
5631
+ ...w.overContract !== void 0 ? { overContract: w.overContract } : {},
5632
+ /*
5633
+ * 부하를 셀 근거가 없었다는 사실을 코드로 낸다 — 화면이 「0 kW」와 「못 쟀다」를 구별할 수 있게.
5634
+ *
5635
+ * 이름이 조건과 정확히 같아야 한다: `samples` 는 **kW 를 실은 표본**의 수다. 표본은 왔는데
5636
+ * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
5637
+ * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
5638
+ */
5639
+ ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {}
5640
+ });
5641
+ if (w.maxKW !== void 0 && (this.peak === void 0 || w.maxKW > this.peak.kW)) {
5642
+ this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
5643
+ this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {} });
5644
+ }
5645
+ this.open = void 0;
5646
+ }
5647
+ return done;
5648
+ }
5649
+ /**
5650
+ * 열린 구간의 판정 — **이대로 가면 계약을 넘는가.**
5651
+ *
5652
+ * 예측은 「지금까지의 평균 부하가 구간 끝까지 이어진다」다. 단순하지만 그 가정을 **값과 함께 낸다**
5653
+ * (`projectionBasis`) — 근거를 감춘 예측은 사용자가 검증할 수 없다. 남은 시간이 짧을수록 이 예측은
5654
+ * 실제에 가까워진다(구간 초반의 경보는 성급할 수 있다는 뜻이고, 그것도 사용자가 알아야 한다).
5655
+ *
5656
+ * 넘을 것 같으면 **제안**을 낸다 — 무엇을 줄일 수 있는지는 모델이 선언한 감축 가능 설비가 답한다.
5657
+ * 우리는 끄지 않는다.
5658
+ */
5659
+ judgeOpenWindow(atMs) {
5660
+ const w = this.open;
5661
+ if (!w || w.contractKW === void 0 || w.meanKW === void 0) return;
5662
+ if (this.suggestedFor === w.startMs) return;
5663
+ const elapsed = Math.max(1, atMs - w.startMs);
5664
+ const projected = (w.meanKW * elapsed + w.meanKW * (w.endMs - atMs)) / this.windowMs;
5665
+ if (projected <= w.contractKW) return;
5666
+ this.suggestedFor = w.startMs;
5667
+ this.emitOp(ENERGY_EVENT.drSuggested, {
5668
+ windowStartMs: w.startMs,
5669
+ projectedKW: projected,
5670
+ contractKW: w.contractKW,
5671
+ gapKW: projected - w.contractKW,
5672
+ projectionBasis: "mean-so-far",
5673
+ /* 줄일 수 있는 것 — 선언된 감축 가능 설비다. 우선순위·최소 유지는 현장이 정하므로 여기서
5674
+ 고르지 않고 **후보를 있는 대로** 낸다(고르는 것은 사람의 일이다). */
5675
+ curtailableCandidates: this.curtailableIds()
5676
+ });
5677
+ }
5678
+ /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
5679
+ curtailableIds() {
5680
+ return (this.boardDef?.equipment ?? []).filter((e) => String(e.kind ?? "") === "curtailable-load").map((e) => e.id);
5681
+ }
5682
+ /*
5683
+ * ── 흐름 훅 넷 — **상속의 대가다** (§6 안 A) ─────────────────────────────
5684
+ *
5685
+ * `FlowEngine` 은 이 넷을 abstract 로 요구한다: 도착·오더·배정·작업 완료. 에너지에는 그 넷이 없다
5686
+ * (물건이 도착하지 않고, 주문이 없고, 배정할 자원이 없고, 완료될 작업이 없다). 그래서 여기서
5687
+ * **아무 일도 하지 않는다** — 그런데 조용히 넘기지 않는다: 이 커널이 그런 요청을 받았다는 것은
5688
+ * **배선이 잘못됐다는 사실**이고(EMS 트윈에 물류 명령을 보낸 것), 조용히 넘기면 그 사실이 사라진다.
5689
+ *
5690
+ * 던지지도 않는다: 저널 재생 중이라면 트윈 전체가 멈춘다. 그래서 커널이 담을 줄 모르는 사건을
5691
+ * 세는 자리(`ObservedReducer.unhandled`)와 같은 규율으로, **개수를 세어 상태로 낸다.**
5692
+ */
5693
+ flowRequests = /* @__PURE__ */ new Map();
5694
+ noteFlowRequest(hook) {
5695
+ this.flowRequests.set(hook, (this.flowRequests.get(hook) ?? 0) + 1);
5696
+ }
5697
+ onArrival() {
5698
+ this.noteFlowRequest("onArrival");
5699
+ }
5700
+ onOrder() {
5701
+ this.noteFlowRequest("onOrder");
5702
+ }
5703
+ allocate() {
5704
+ this.noteFlowRequest("allocate");
5705
+ }
5706
+ onTaskComplete() {
5707
+ this.noteFlowRequest("onTaskComplete");
5708
+ }
5709
+ /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
5710
+ tick(dtMs) {
5711
+ super.tick(dtMs);
5712
+ this.closeDue(this.clockMs);
5713
+ }
5714
+ getSnapshot() {
5715
+ const snap = super.getSnapshot();
5716
+ const contractKW = this.declaredContractKW();
5717
+ const energy = {
5718
+ points: [...this.points.values()].map((p) => ({ ...p })),
5719
+ ...this.open ? {
5720
+ open: {
5721
+ ...this.open,
5722
+ ...this.open.meanKW !== void 0 ? { projectedKW: this.open.meanKW, projectionBasis: "mean-so-far" } : {}
5723
+ }
5724
+ } : {},
5725
+ closed: this.closed.map((w) => ({ ...w })),
5726
+ closedTotal: this.closedTotal,
5727
+ ...this.peak ? { peakSince: { ...this.peak } } : {},
5728
+ ...contractKW !== void 0 ? { contractKW } : {},
5729
+ /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
5730
+ ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
5731
+ };
5732
+ return { ...snap, energy };
5733
+ }
5734
+ };
5735
+
5273
5736
  // src/vocabulary.ts
5274
5737
  var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
5275
5738
  var VOCABULARY_EXCEPTIONS = [
@@ -5322,11 +5785,18 @@ function retiredVocabularyIn(line) {
5322
5785
  CAPABILITY_KEYS,
5323
5786
  CBV_BIZSTEP,
5324
5787
  CMD,
5788
+ DEMAND_WINDOW_MS,
5325
5789
  DISP,
5326
5790
  DOMAIN_CATALOG,
5327
5791
  DOMAIN_SYSTEMS,
5792
+ EMS_EQUIPMENT_TYPES,
5793
+ EMS_LOCATION_TYPES,
5794
+ EMS_PROPERTY,
5795
+ EMS_TYPES,
5796
+ ENERGY_EVENT,
5328
5797
  EPCIS_CONTEXT,
5329
5798
  EQUIPMENT_LEVEL,
5799
+ EmsKernel,
5330
5800
  EventJournal,
5331
5801
  FlowEngine,
5332
5802
  GUARD_PRAGMA,
@@ -5365,6 +5835,8 @@ function retiredVocabularyIn(line) {
5365
5835
  activeShiftOf,
5366
5836
  aggregationEvent,
5367
5837
  analyzeCapacity,
5838
+ axesOfSystem,
5839
+ axisAppliesTo,
5368
5840
  axisInfo,
5369
5841
  axisSource,
5370
5842
  capabilitiesForType,
@@ -5375,6 +5847,7 @@ function retiredVocabularyIn(line) {
5375
5847
  constantDuration,
5376
5848
  conversionFactorOf,
5377
5849
  counterfactualAt,
5850
+ demandWindowStart,
5378
5851
  deriveAttentions,
5379
5852
  documentPath,
5380
5853
  dueStatusOf,
@@ -5392,6 +5865,7 @@ function retiredVocabularyIn(line) {
5392
5865
  isEquipmentLevel,
5393
5866
  isoDurationHours,
5394
5867
  itemKeyOf,
5868
+ levelOfLocationType,
5395
5869
  lgtinClass,
5396
5870
  locationStatusOf,
5397
5871
  mapRecord,