@operato/twin-kernel 0.6.14 → 0.7.1

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 (45) hide show
  1. package/dist/capability.d.ts +29 -1
  2. package/dist/capability.js +23 -1
  3. package/dist/capacity.d.ts +5 -5
  4. package/dist/capacity.js +7 -7
  5. package/dist/contract.d.ts +176 -32
  6. package/dist/contract.js +66 -7
  7. package/dist/counterfactual.d.ts +1 -1
  8. package/dist/counterfactual.js +2 -2
  9. package/dist/domain-catalog.d.ts +83 -17
  10. package/dist/domain-catalog.js +99 -14
  11. package/dist/domain-definition.d.ts +17 -1
  12. package/dist/ems-kernel.d.ts +83 -0
  13. package/dist/ems-kernel.js +307 -0
  14. package/dist/ems-profile.d.ts +16 -0
  15. package/dist/ems-profile.js +133 -0
  16. package/dist/energy-attribution.d.ts +208 -0
  17. package/dist/energy-attribution.js +229 -0
  18. package/dist/energy-ingest.d.ts +39 -0
  19. package/dist/energy-ingest.js +91 -0
  20. package/dist/epcis.d.ts +3 -3
  21. package/dist/epcis.js +2 -2
  22. package/dist/event-journal.d.ts +2 -2
  23. package/dist/event-journal.js +1 -1
  24. package/dist/flow-engine.d.ts +20 -20
  25. package/dist/flow-engine.js +40 -40
  26. package/dist/forecast.js +1 -1
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +5 -0
  29. package/dist/kernel.d.ts +3 -3
  30. package/dist/kernel.js +5 -5
  31. package/dist/mes-kernel.d.ts +2 -2
  32. package/dist/mes-kernel.js +2 -2
  33. package/dist/mes-profile.js +25 -1
  34. package/dist/observed-reducer.d.ts +25 -4
  35. package/dist/observed-reducer.js +30 -9
  36. package/dist/operations-capability.d.ts +8 -8
  37. package/dist/operations-capability.js +2 -2
  38. package/dist/task-fold.d.ts +1 -1
  39. package/dist/task-fold.js +1 -1
  40. package/dist/twin-observer.js +1 -1
  41. package/dist/wms-profile.d.ts +0 -11
  42. package/dist/wms-profile.js +17 -2
  43. package/dist/yms-profile.js +16 -1
  44. package/dist-cjs/index.cjs +814 -56
  45. package/package.json +1 -1
@@ -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,9 @@ __export(index_exports, {
70
77
  activeShiftOf: () => activeShiftOf,
71
78
  aggregationEvent: () => aggregationEvent,
72
79
  analyzeCapacity: () => analyzeCapacity,
80
+ attributeEnergy: () => attributeEnergy,
81
+ axesOfSystem: () => axesOfSystem,
82
+ axisAppliesTo: () => axisAppliesTo,
73
83
  axisInfo: () => axisInfo,
74
84
  axisSource: () => axisSource,
75
85
  capabilitiesForType: () => capabilitiesForType,
@@ -80,10 +90,13 @@ __export(index_exports, {
80
90
  constantDuration: () => constantDuration,
81
91
  conversionFactorOf: () => conversionFactorOf,
82
92
  counterfactualAt: () => counterfactualAt,
93
+ demandWindowStart: () => demandWindowStart,
83
94
  deriveAttentions: () => deriveAttentions,
84
95
  documentPath: () => documentPath,
85
96
  dueStatusOf: () => dueStatusOf,
86
97
  effectivityAt: () => effectivityAt,
98
+ energyIntensity: () => energyIntensity,
99
+ energyOfWindows: () => energyOfWindows,
87
100
  fefoPolicy: () => fefoPolicy,
88
101
  firstFitPolicy: () => firstFitPolicy,
89
102
  foldJobResponses: () => foldJobResponses,
@@ -94,9 +107,12 @@ __export(index_exports, {
94
107
  inWorkCalendar: () => inWorkCalendar,
95
108
  inWorkCalendarAt: () => inWorkCalendarAt,
96
109
  ingest: () => ingest,
110
+ ingestEnergyRecords: () => ingestEnergyRecords,
111
+ isEnergyRecord: () => isEnergyRecord,
97
112
  isEquipmentLevel: () => isEquipmentLevel,
98
113
  isoDurationHours: () => isoDurationHours,
99
114
  itemKeyOf: () => itemKeyOf,
115
+ levelOfLocationType: () => levelOfLocationType,
100
116
  lgtinClass: () => lgtinClass,
101
117
  locationStatusOf: () => locationStatusOf,
102
118
  mapRecord: () => mapRecord,
@@ -167,7 +183,7 @@ var EQUIPMENT_LEVEL = [
167
183
  function isEquipmentLevel(v) {
168
184
  return typeof v === "string" && EQUIPMENT_LEVEL.includes(v);
169
185
  }
170
- function hierarchyOf(s) {
186
+ function hierarchyOf(s, levelOfType) {
171
187
  const push = (m, k, v) => {
172
188
  const cur = m.get(k);
173
189
  if (cur) cur.push(v);
@@ -210,7 +226,7 @@ function hierarchyOf(s) {
210
226
  return out;
211
227
  };
212
228
  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]));
229
+ const levelOf = new Map(s.locations.map((n) => [n.id, n.level ?? (n.type ? levelOfType?.(n.type) : void 0)]));
214
230
  return {
215
231
  childrenOf: (id) => [...kids.get(id) ?? []],
216
232
  ancestorsOf,
@@ -455,7 +471,7 @@ var OP_EVENT = {
455
471
  *
456
472
  * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
457
473
  * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
458
- * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
474
+ * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
459
475
  */
460
476
  /*
461
477
  * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
@@ -464,6 +480,35 @@ var OP_EVENT = {
464
480
  */
465
481
  attentionAck: "attention.acked"
466
482
  };
483
+ var ENERGY_EVENT = {
484
+ /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
485
+ measured: "energy.measured",
486
+ /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
487
+ demandWindow: "energy.demand.window",
488
+ /**
489
+ * 피크 경신 — 월 최대 수요가 갱신됐다.
490
+ *
491
+ * 구간 마감에서 파생되지만 **사실로 남긴다**: 요금의 근거이고, 나중에 "언제 무엇 때문에 올랐나" 를
492
+ * 물을 때 파생으로는 답할 수 없다(그 순간의 부하 구성이 사라진다).
493
+ */
494
+ peak: "energy.peak",
495
+ /** 요금 구간 전환 — 경부하·중간부하·최대부하. 달력이 아니라 사건이다(계절제·특례로 바뀐다). */
496
+ tariffShift: "energy.tariff.shift",
497
+ /** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
498
+ generated: "energy.generated",
499
+ /** 저장(ESS 충전). */
500
+ stored: "energy.stored",
501
+ /** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
502
+ discharged: "energy.discharged",
503
+ /**
504
+ * 수요 제어 제안 — **제안이지 명령이 아니다.**
505
+ *
506
+ * 이 트윈은 차단·투입을 하지 않는다(안전 계통은 범위 밖: `ems.md` §1). 이대로면 계약을 넘는다는
507
+ * 판단과 무엇을 줄이면 되는지를 낼 뿐이고, 집행은 사람과 그 시스템의 몫이다. 이름을 `suggested`
508
+ * 로 둔 이유가 그것이다 — 저널만 보고도 "우리가 끈 것이 아니다" 를 알 수 있어야 한다.
509
+ */
510
+ drSuggested: "energy.dr.suggested"
511
+ };
467
512
  var CMD = {
468
513
  orderHold: "order.hold",
469
514
  orderResume: "order.resume",
@@ -978,11 +1023,13 @@ var ObservedReducer = class {
978
1023
  revision = 0;
979
1024
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
980
1025
  corrections = [];
1026
+ /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
1027
+ unhandled = /* @__PURE__ */ new Map();
981
1028
  /**
982
1029
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
983
1030
  *
984
1031
  * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
985
- * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
1032
+ * 어긋난다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
986
1033
  */
987
1034
  shifts = /* @__PURE__ */ new Map();
988
1035
  /**
@@ -1005,14 +1052,14 @@ var ObservedReducer = class {
1005
1052
  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
1053
  }
1007
1054
  /**
1008
- * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
1055
+ * **구조를 전환한다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
1009
1056
  *
1010
1057
  * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
1011
1058
  * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
1012
1059
  * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
1013
1060
  *
1014
1061
  * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
1015
- * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
1062
+ * 불러 전환한 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
1016
1063
  *
1017
1064
  * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
1018
1065
  * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
@@ -1131,7 +1178,7 @@ var ObservedReducer = class {
1131
1178
  /**
1132
1179
  * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
1133
1180
  *
1134
- * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
1181
+ * 미러는 스스로 시간을 진행시키지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
1135
1182
  * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
1136
1183
  * 판정하지 않는다(모르면 단정하지 않는다).
1137
1184
  */
@@ -1248,7 +1295,20 @@ var ObservedReducer = class {
1248
1295
  });
1249
1296
  break;
1250
1297
  }
1298
+ default:
1299
+ this.noteUnhandled(e);
1300
+ }
1301
+ }
1302
+ /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
1303
+ noteUnhandled(e) {
1304
+ const at = Date.parse(String(e.eventTime ?? ""));
1305
+ const cur = this.unhandled.get(e.eventType) ?? { count: 0 };
1306
+ cur.count++;
1307
+ if (Number.isFinite(at)) {
1308
+ if (cur.firstAtMs === void 0 || at < cur.firstAtMs) cur.firstAtMs = at;
1309
+ if (cur.lastAtMs === void 0 || at > cur.lastAtMs) cur.lastAtMs = at;
1251
1310
  }
1311
+ this.unhandled.set(e.eventType, cur);
1252
1312
  }
1253
1313
  applyEpcis(ev, envelope) {
1254
1314
  if (ev.errorDeclaration) {
@@ -1351,7 +1411,7 @@ var ObservedReducer = class {
1351
1411
  *
1352
1412
  * **한 항목뿐이면 담지 않는다.** 그 하나는 `qty`/`uom` 이 이미 무손실로 들고 있고, 1개짜리
1353
1413
  * 배열을 더 두면 같은 사실이 두 곳에 생긴다(그리고 시뮬은 그 배열을 만들지 않아 두 구동이
1354
- * 갈라진다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
1414
+ * 어긋난다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
1355
1415
  * 읽을 때는 단위를 가리지 않는 `quantityIn` 을 쓴다(단일이든 복수든 같은 답).
1356
1416
  */
1357
1417
  quantities: (() => {
@@ -1439,6 +1499,7 @@ var ObservedReducer = class {
1439
1499
  return {
1440
1500
  revision: this.revision,
1441
1501
  ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
1502
+ ...this.unhandled.size ? { unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })).sort((a, b) => b.count - a.count) } : {},
1442
1503
  locations: [...this.master.values()].map((n) => {
1443
1504
  const occupancy = occ.get(n.id) ?? 0;
1444
1505
  const status = locationStatusOf({ occupancy, capacity: n.capacity });
@@ -1525,14 +1586,148 @@ var BTT = {
1525
1586
  so: "urn:epcglobal:cbv:btt:so"
1526
1587
  };
1527
1588
  var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
1589
+ var WMS_LOCATION_LEVEL = {
1590
+ dock: "StorageZone",
1591
+ storage: "StorageUnit",
1592
+ staging: "StorageZone",
1593
+ "dock-ship": "StorageZone",
1594
+ "vas-station": "WorkCell"
1595
+ };
1528
1596
  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"] },
1597
+ ...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"] })),
1598
+ { 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
1599
  { key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] },
1532
1600
  /** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
1533
1601
  { key: "packer", role: "equipment", label: "twin.type.packer", standardClass: { epcis: "object", isa95: "Equipment" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1534
1602
  ];
1535
1603
 
1604
+ // src/ems-profile.ts
1605
+ var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
1606
+ var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
1607
+ var EMS_PROPERTY = {
1608
+ /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
1609
+ contractKW: "contract.kW"
1610
+ };
1611
+ var EMS_TYPES = [
1612
+ /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
1613
+ {
1614
+ key: "incoming",
1615
+ role: "location",
1616
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1617
+ level: "Other",
1618
+ label: "twin.type.incoming",
1619
+ /* 수전 지점 — 계약전력이 걸리는 자리이고, 요금의 근거가 되는 수요는 여기서 잰다.
1620
+ ISO 50001 의 「에너지 유입」 경계이기도 하다(조직의 에너지 검토가 여기서 시작한다). */
1621
+ standardClass: { iec61850: "MMTR", iso50001: "EnergyInput" },
1622
+ identity: { scheme: "kernel:id" },
1623
+ capabilities: ["metered"]
1624
+ },
1625
+ {
1626
+ key: "feeder",
1627
+ role: "location",
1628
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1629
+ level: "Other",
1630
+ label: "twin.type.feeder",
1631
+ /* 분기 회로 — 부하 분해의 단위. 계약전력의 하위 배분이 여기서 정해진다. */
1632
+ standardClass: { iec61850: "Feeder", iso50001: "EnergyUse" },
1633
+ identity: { scheme: "kernel:id" },
1634
+ capabilities: ["metered"]
1635
+ },
1636
+ {
1637
+ key: "submeter-zone",
1638
+ role: "location",
1639
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
1640
+ level: "Other",
1641
+ label: "twin.type.submeter-zone",
1642
+ /*
1643
+ * 구역 계량 — 여러 부하를 한 계량기로 묶어 재는 자리(공조·조명처럼 개별 계량이 없는 것들).
1644
+ *
1645
+ * ISO 50001 의 **SEU**(유의 에너지 사용처)가 대개 이 알갱이다. 개별 설비까지 재지 못하는 현장이
1646
+ * 많고, 그것을 「모른다」로 두는 대신 **묶음으로 아는 것**이 정직하다.
1647
+ */
1648
+ standardClass: { iec61850: "MMXU", iso50001: "SEU" },
1649
+ identity: { scheme: "kernel:id" },
1650
+ capabilities: ["metered"]
1651
+ },
1652
+ /* ── 설비: 계량 지점과 에너지 자원 ──────────────────────────────────────── */
1653
+ {
1654
+ key: "meter",
1655
+ role: "equipment",
1656
+ label: "twin.type.meter",
1657
+ /* 계량기 — 측정 논리 노드(MMXU=측정단위, MMTR=적산). 자산으로도 하나다(ISO 55000). */
1658
+ standardClass: { iec61850: "MMXU", iso55000: "Asset", iso50001: "MeasurementPoint" },
1659
+ identity: { scheme: "kernel:id" },
1660
+ capabilities: ["metered", "operable"]
1661
+ },
1662
+ {
1663
+ key: "breaker",
1664
+ role: "equipment",
1665
+ label: "twin.type.breaker",
1666
+ /*
1667
+ * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
1668
+ * 상태를 읽어 계통 구성을 알 뿐이다 — 그래서 능력은 `operable` 만이고 `curtailable` 이 아니다.
1669
+ */
1670
+ standardClass: { iec61850: "XCBR", iso55000: "Asset" },
1671
+ identity: { scheme: "kernel:id" },
1672
+ capabilities: ["operable"]
1673
+ },
1674
+ {
1675
+ key: "pv-array",
1676
+ role: "equipment",
1677
+ label: "twin.type.pv-array",
1678
+ /* 태양광 어레이 — IEC 61850-7-420(분산자원)의 `DPVA`. 발전과 계량은 다른 능력이다. */
1679
+ standardClass: { iec61850: "DPVA", iso55000: "Asset", iso50001: "RenewableSupply" },
1680
+ identity: { scheme: "kernel:id" },
1681
+ capabilities: ["generating", "metered", "operable"]
1682
+ },
1683
+ {
1684
+ key: "battery",
1685
+ role: "equipment",
1686
+ label: "twin.type.battery",
1687
+ /* 축전지(ESS) — `ZBAT`. 충전·방전을 나눠 재고, 저장은 보관(`storable`)이 아니다(물건이 아니다). */
1688
+ standardClass: { iec61850: "ZBAT", iso55000: "Asset" },
1689
+ identity: { scheme: "kernel:id" },
1690
+ capabilities: ["storing", "metered", "operable"]
1691
+ },
1692
+ {
1693
+ key: "utility",
1694
+ role: "equipment",
1695
+ label: "twin.type.utility",
1696
+ /*
1697
+ * 공통 설비 — 공조·컴프레서·칠러·조명·폐수처리처럼 **어느 공정에도 귀속되지 않는** 소비처.
1698
+ *
1699
+ * ── 왜 따로 있나 (2026-08-14) ────────────────────────────────────────────
1700
+ * 물류·생산 트윈에서 이런 것들은 **설비가 아니다**(공정에 매핑되지 않으므로 자원 축에 없다).
1701
+ * 그런데 에너지에서는 소비의 절반을 차지하고 감축 후보 1순위다 — 담을 자리가 반드시 있어야 한다.
1702
+ *
1703
+ * 처음에는 `curtailable-load` 하나로 받으려 했다. 그런데 그 이름은 **「줄일 수 있다」고 주장**한다:
1704
+ * 폐수처리·방폭 환기·서버실 냉방은 공통이지만 줄일 수 없고, 그것을 감축 가능으로 두면 트윈이
1705
+ * 「이걸 줄이면 됩니다」라는 거짓 제안을 한다. 공통성과 감축 가능성은 **다른 축**이다.
1706
+ *
1707
+ * 표준: IEC 61850 에 「공통 설비」라는 논리 노드는 없다(설비 종류마다 다른 노드다) — 비운다.
1708
+ * ISO 50001 의 SEU 는 이 부류를 가장 많이 가리킨다(유의 에너지 사용처).
1709
+ */
1710
+ standardClass: { iso50001: "SEU", iso55000: "Asset" },
1711
+ identity: { scheme: "kernel:id" },
1712
+ capabilities: ["metered", "operable"]
1713
+ },
1714
+ {
1715
+ key: "curtailable-load",
1716
+ role: "equipment",
1717
+ label: "twin.type.curtailable-load",
1718
+ /*
1719
+ * 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
1720
+ *
1721
+ * 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
1722
+ * 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
1723
+ * 적합성 표가 거짓을 말한다.
1724
+ */
1725
+ standardClass: { iso50001: "SEU" },
1726
+ identity: { scheme: "kernel:id" },
1727
+ capabilities: ["curtailable", "metered", "operable"]
1728
+ }
1729
+ ];
1730
+
1536
1731
  // src/capability.ts
1537
1732
  var CAPABILITIES = {
1538
1733
  operable: {
@@ -1571,9 +1766,49 @@ var CAPABILITIES = {
1571
1766
  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
1767
  stateFields: ["lifecycle", "progress", "held"],
1573
1768
  results: ["lifecycleChanged"]
1769
+ },
1770
+ /*
1771
+ * ── 에너지 능력 넷 ──────────────────────────────────────────────────────
1772
+ * 상태 필드는 기존 능력과 **직교**한다(원칙 ②) — 어느 필드도 두 능력에 겹치지 않는다.
1773
+ * 계량은 `operable`(가동 상태)과도 직교한다: 도는 것과 재는 것은 다른 사실이고, 멈춘 설비도
1774
+ * 대기전력을 쓴다.
1775
+ */
1776
+ metered: {
1777
+ key: "metered",
1778
+ label: "twin.capability.metered",
1779
+ 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).",
1780
+ stateFields: ["kW", "kWh", "powerFactor"],
1781
+ models: ["MeteredState"],
1782
+ results: ["measured"]
1783
+ },
1784
+ curtailable: {
1785
+ key: "curtailable",
1786
+ label: "twin.capability.curtailable",
1787
+ 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.",
1788
+ stateFields: ["curtailable", "minKW"],
1789
+ models: ["CurtailableState"],
1790
+ invariants: ["minKW >= 0"],
1791
+ results: ["drSuggested"]
1792
+ },
1793
+ generating: {
1794
+ key: "generating",
1795
+ label: "twin.capability.generating",
1796
+ 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.",
1797
+ stateFields: ["generatedKW", "exportKW"],
1798
+ models: ["GeneratingState"],
1799
+ results: ["generated"]
1800
+ },
1801
+ storing: {
1802
+ key: "storing",
1803
+ label: "twin.capability.storing",
1804
+ 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).",
1805
+ stateFields: ["soc", "chargeKW", "dischargeKW"],
1806
+ models: ["StoringState"],
1807
+ invariants: ["0 <= soc <= 100"],
1808
+ results: ["stored", "discharged"]
1574
1809
  }
1575
1810
  };
1576
- var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable"];
1811
+ var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "metered", "curtailable", "generating", "storing"];
1577
1812
  function stateFieldsOf(caps) {
1578
1813
  const out = /* @__PURE__ */ new Set();
1579
1814
  for (const c of caps) for (const f of CAPABILITIES[c]?.stateFields ?? []) out.add(f);
@@ -1598,8 +1833,14 @@ function graiUri(companyPrefix, assetType, serial) {
1598
1833
  return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
1599
1834
  }
1600
1835
  var YMS_LOCATION_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
1836
+ var YMS_LOCATION_LEVEL = {
1837
+ gate: "Other",
1838
+ "yard-slot": "StorageUnit",
1839
+ "dock-door": "StorageUnit",
1840
+ staging: "StorageZone"
1841
+ };
1601
1842
  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"] })),
1843
+ ...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
1844
  { key: "hostler", role: "equipment", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
1604
1845
  ];
1605
1846
 
@@ -1625,8 +1866,16 @@ var MES_LOCATION_CLS = {
1625
1866
  "assembly-line": { isa95: "WorkCenter", epcis: "bizLocation" },
1626
1867
  "fg-store": { epcis: "bizLocation" }
1627
1868
  };
1869
+ var MES_LOCATION_LEVEL = {
1870
+ "raw-store": "StorageZone",
1871
+ "cut-station": "WorkCell",
1872
+ "weld-station": "WorkCell",
1873
+ "paint-booth": "WorkCell",
1874
+ "assembly-line": "ProductionLine",
1875
+ "fg-store": "StorageZone"
1876
+ };
1628
1877
  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"] })),
1878
+ ...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
1879
  { key: "cutter", role: "equipment", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1631
1880
  { key: "welder", role: "equipment", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1632
1881
  { key: "painter", role: "equipment", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
@@ -1639,8 +1888,10 @@ var DOMAIN_CATALOG = {
1639
1888
  // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
1640
1889
  wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
1641
1890
  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) }
1891
+ mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) },
1892
+ ems: { system: "ems", label: "twin.system.ems", types: EMS_TYPES, locationTypes: locationKeys(EMS_TYPES) }
1643
1893
  };
1894
+ var LOGISTICS = ["wms", "yms", "mes"];
1644
1895
  var TWIN_AXES = [
1645
1896
  {
1646
1897
  axis: "locations",
@@ -1663,7 +1914,8 @@ var TWIN_AXES = [
1663
1914
  path: "persons",
1664
1915
  label: "twin.axis.persons",
1665
1916
  kind: "instance",
1666
- standardClass: { isa95: "Person" }
1917
+ standardClass: { isa95: "Person" },
1918
+ systems: LOGISTICS
1667
1919
  },
1668
1920
  {
1669
1921
  axis: "assets",
@@ -1677,7 +1929,8 @@ var TWIN_AXES = [
1677
1929
  path: "personnelClasses",
1678
1930
  label: "twin.axis.personnelClasses",
1679
1931
  kind: "class",
1680
- standardClass: { isa95: "PersonnelClass" }
1932
+ standardClass: { isa95: "PersonnelClass" },
1933
+ systems: LOGISTICS
1681
1934
  },
1682
1935
  {
1683
1936
  axis: "equipmentClasses",
@@ -1698,28 +1951,32 @@ var TWIN_AXES = [
1698
1951
  path: "materialDefinitions",
1699
1952
  label: "twin.axis.materialDefinitions",
1700
1953
  kind: "class",
1701
- standardClass: { isa95: "MaterialDefinition" }
1954
+ standardClass: { isa95: "MaterialDefinition" },
1955
+ systems: LOGISTICS
1702
1956
  },
1703
1957
  {
1704
1958
  axis: "materialClasses",
1705
1959
  path: "materialClasses",
1706
1960
  label: "twin.axis.materialClasses",
1707
1961
  kind: "class",
1708
- standardClass: { isa95: "MaterialClass" }
1962
+ standardClass: { isa95: "MaterialClass" },
1963
+ systems: LOGISTICS
1709
1964
  },
1710
1965
  {
1711
1966
  axis: "operations",
1712
1967
  path: "operations",
1713
1968
  label: "twin.axis.operations",
1714
1969
  kind: "spec",
1715
- standardClass: { isa95: "OperationsSegment" }
1970
+ standardClass: { isa95: "OperationsSegment" },
1971
+ systems: LOGISTICS
1716
1972
  },
1717
1973
  {
1718
1974
  axis: "productionSpec",
1719
1975
  path: "productionSpec",
1720
1976
  label: "twin.axis.productionSpec",
1721
1977
  kind: "spec",
1722
- standardClass: { isa95: "OperationsSegment" }
1978
+ standardClass: { isa95: "OperationsSegment" },
1979
+ systems: LOGISTICS
1723
1980
  },
1724
1981
  /*
1725
1982
  * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
@@ -1732,21 +1989,24 @@ var TWIN_AXES = [
1732
1989
  path: "productionSpec.definition.recipes",
1733
1990
  label: "twin.axis.recipes",
1734
1991
  kind: "spec",
1735
- standardClass: { isa95: "OperationsSegment" }
1992
+ standardClass: { isa95: "OperationsSegment" },
1993
+ systems: LOGISTICS
1736
1994
  },
1737
1995
  {
1738
1996
  axis: "routes",
1739
1997
  path: "productionSpec.definition.routes",
1740
1998
  label: "twin.axis.routes",
1741
1999
  kind: "spec",
1742
- standardClass: { isa95: "OperationsSegment" }
2000
+ standardClass: { isa95: "OperationsSegment" },
2001
+ systems: LOGISTICS
1743
2002
  },
1744
2003
  {
1745
2004
  axis: "materials",
1746
2005
  path: "productionSpec.definition.materials",
1747
2006
  label: "twin.axis.materials",
1748
2007
  kind: "class",
1749
- standardClass: { isa95: "MaterialDefinition" }
2008
+ standardClass: { isa95: "MaterialDefinition" },
2009
+ systems: LOGISTICS
1750
2010
  },
1751
2011
  /*
1752
2012
  * 시험 명세 — **자원 아홉 종류가 가리키는 대상.**
@@ -1786,7 +2046,8 @@ var TWIN_AXES = [
1786
2046
  kind: "instance",
1787
2047
  source: "state",
1788
2048
  historical: true,
1789
- standardClass: { isa95: "OperationsRequest", epcis: "TransactionEvent" }
2049
+ standardClass: { isa95: "OperationsRequest", epcis: "TransactionEvent" },
2050
+ systems: LOGISTICS
1790
2051
  },
1791
2052
  {
1792
2053
  axis: "tasks",
@@ -1794,7 +2055,36 @@ var TWIN_AXES = [
1794
2055
  kind: "instance",
1795
2056
  source: "state",
1796
2057
  historical: true,
1797
- standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" }
2058
+ standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
2059
+ systems: LOGISTICS
2060
+ },
2061
+ /*
2062
+ * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
2063
+ *
2064
+ * 에너지 트윈의 개체 대부분은 **이미 있는 축**이 답한다: 전기 구간은 `locations`, 계량기·차단기·
2065
+ * 태양광·축전지·감축 부하는 `equipment` 다(카탈로그가 그 타입들을 그 역할로 선언한다). 그것들을
2066
+ * 새 축으로 다시 세우면 같은 것이 두 곳에서 세어진다 — 개념 지도가 계량기를 두 번 보여 준다.
2067
+ *
2068
+ * 정말로 새로운 것은 **수요 구간**이다: 자원이 아니고, 선언이 아니고, 15분마다 닫히는 **사실**이다.
2069
+ * 그것이 요금의 알갱이이고 피크의 근거다(피크는 마감된 구간의 최대이므로 파생이다 — 축이 아니다).
2070
+ *
2071
+ * ── 표준 칸을 비운다 ──────────────────────────────────────────────────────
2072
+ * 15분 수요 구간은 **요금 제도의 알갱이**다(계약·TOU). ISO 50001 은 경영 체계를, IEC 61850 은 설비
2073
+ * 데이터 모델을 말하고, 둘 다 이 구간을 정의하지 않는다. 가까운 이름을 적으면 적합성 표가 거짓을
2074
+ * 말하므로 비워 둔다 — 「표준에 자리가 없으면 빈 객체」라는 이 선언의 규율 그대로다.
2075
+ *
2076
+ * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
2077
+ * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
2078
+ */
2079
+ {
2080
+ axis: "demandWindows",
2081
+ path: "energy.closed",
2082
+ label: "twin.axis.demandWindows",
2083
+ kind: "instance",
2084
+ source: "state",
2085
+ historical: true,
2086
+ standardClass: {},
2087
+ systems: ["ems"]
1798
2088
  }
1799
2089
  ];
1800
2090
  var TWIN_RELATIONS = [
@@ -1816,7 +2106,7 @@ var TWIN_RELATIONS = [
1816
2106
  { from: "routes", field: "steps[]", target: { kind: "axis", axis: "operations" }, via: "twin.rel.step" },
1817
2107
  /*
1818
2108
  * 일정·실적의 관계 — **관측 관계다.** 선언 관계는 "그렇게 만들기로 했다" 이고, 이것은 "그렇게
1819
- * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 있는 카드가 된다(그러면 걸어 들어갈 수 없다).
2109
+ * 일어났다" 다. 관계가 없으면 이 두 축은 지도에서 연결되지 않은 카드가 된다(그러면 걸어 들어갈 수 없다).
1820
2110
  */
1821
2111
  /*
1822
2112
  * 오더가 무엇을 만드나 — **자재 키가 아니라 GS1 품목 참조다**(`lines[].gtin`). 자재와 이어 주는
@@ -1912,7 +2202,20 @@ var relationsTo = (axis) => TWIN_RELATIONS.filter((r) => r.target.kind === "axis
1912
2202
  function axisInfo(axis) {
1913
2203
  return TWIN_AXES.find((a) => a.axis === axis);
1914
2204
  }
1915
- var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
2205
+ var DOMAIN_SYSTEMS = ["wms", "yms", "mes", "ems"];
2206
+ var axisAppliesTo = (axis, system) => {
2207
+ const declared = TWIN_AXES.find((a) => a.axis === axis)?.systems;
2208
+ if (!declared || !system) return true;
2209
+ if (!DOMAIN_SYSTEMS.includes(system)) return true;
2210
+ return declared.includes(system);
2211
+ };
2212
+ var levelOfLocationType = (typeKey, system) => {
2213
+ const pick = (sys) => DOMAIN_CATALOG[sys]?.types.find((t) => t.role === "location" && t.key === typeKey)?.level;
2214
+ if (system && DOMAIN_SYSTEMS.includes(system)) return pick(system);
2215
+ const found = [...new Set(DOMAIN_SYSTEMS.map(pick).filter(Boolean))];
2216
+ return found.length === 1 ? found[0] : void 0;
2217
+ };
2218
+ var axesOfSystem = (system) => TWIN_AXES.filter((a) => axisAppliesTo(a.axis, system));
1916
2219
  function capabilitiesForType(system, typeKey) {
1917
2220
  return DOMAIN_CATALOG[system]?.types.find((t) => t.key === typeKey)?.capabilities ?? [];
1918
2221
  }
@@ -2521,7 +2824,7 @@ var FlowEngine = class {
2521
2824
  /*
2522
2825
  * ── 선언 하나를 상태 하나로 짓는 자리 ─────────────────────────────────────
2523
2826
  * 최초 적재(`loadTwinModel`)와 가동 중 교체(`adoptStructure`)가 **같은 구성을 쓴다.** 두 곳에서
2524
- * 따로 지으면 곧 갈라지고, 갈라진 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2827
+ * 따로 지으면 곧 갈라지고, 어긋난 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2525
2828
  */
2526
2829
  buildLocation(n) {
2527
2830
  return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId };
@@ -2565,7 +2868,7 @@ var FlowEngine = class {
2565
2868
  this.observeMode = true;
2566
2869
  }
2567
2870
  /**
2568
- * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2871
+ * 돌면서 공장을 전환한다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2569
2872
  *
2570
2873
  * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
2571
2874
  * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
@@ -2578,7 +2881,7 @@ var FlowEngine = class {
2578
2881
  * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
2579
2882
  * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
2580
2883
  *
2581
- * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
2884
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 주입한다 — 다음 스냅샷에서 관측층이
2582
2885
  * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
2583
2886
  */
2584
2887
  adoptStructure(def) {
@@ -2670,7 +2973,7 @@ var FlowEngine = class {
2670
2973
  this.items.clear();
2671
2974
  for (const it of snap.items) {
2672
2975
  this.items.set(itemKeyOf(it), {
2673
- /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
2976
+ /* 품번 키·로트는 식별자에서 파생되므로 주입하지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
2674
2977
  epc: it.epc,
2675
2978
  location: it.location,
2676
2979
  disposition: it.disposition ?? DISP.sellable,
@@ -2706,13 +3009,13 @@ var FlowEngine = class {
2706
3009
  * **잰 값을 이어받으면 잰 구간도 이어받아야 한다.**
2707
3010
  *
2708
3011
  * 카운터만 되살리고 창을 지금으로 두면 둘이 어긋난다: 이어받은 가동 8시간이 방금 시작한 창
2709
- * 안에 앉아, 놀고 있는 설비가 **가동률 100%·유휴 0초**로 보인다(화면에서 관측됨).
3012
+ * 안에 앉아, 대기 중인 설비가 **가동률 100%·유휴 0초**로 보인다(화면에서 관측됨).
2710
3013
  *
2711
3014
  * 창은 지표 자신이 말해 준다 — 계획 시간은 가동·준비·고장·유휴의 합이다. 그래서 그 합만큼
2712
3015
  * 뒤로 물려 창을 다시 세운다. 지표가 없으면 창도 없다(그때는 지금부터 새로 잰다).
2713
3016
  */
2714
3017
  ...oee ? { metricsSinceMs: this.clockMs - ((oee.runMs ?? 0) + (oee.setupMs ?? 0) + (oee.downMs ?? 0) + (oee.idleMs ?? 0)) } : {},
2715
- /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 굴린다
3018
+ /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
2716
3019
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
2717
3020
  ...m.held ? { held: true } : {},
2718
3021
  /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
@@ -3070,7 +3373,7 @@ var FlowEngine = class {
3070
3373
  return st;
3071
3374
  }),
3072
3375
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
3073
- * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
3376
+ * 다른 커널을 주입하면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
3074
3377
  * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
3075
3378
  tasks: [...this.tasks.values()].map((t) => ({
3076
3379
  id: t.id,
@@ -3154,7 +3457,7 @@ var FlowEngine = class {
3154
3457
  }
3155
3458
  /**
3156
3459
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
3157
- * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 굴려 forecast·발산(predicted vs actual) 검사에 쓴다.
3460
+ * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
3158
3461
  * fork 는 자기 구독자·시나리오를 갖고 원본과 격리(handlers·gens 비움, generating=false).
3159
3462
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
3160
3463
  */
@@ -3180,7 +3483,7 @@ var FlowEngine = class {
3180
3483
  * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
3181
3484
  *
3182
3485
  * ── 관측 모드에서 시계가 멈춰 있었다 ────────────────────────────────────
3183
- * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 굴리지 않는다). 그래서 라이브 트윈의 "지금" 이
3486
+ * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 진행시키지 않는다). 그래서 라이브 트윈의 "지금" 이
3184
3487
  * **BASE_EPOCH(2026-01-01)에 얼어 있었다.** 실 이벤트는 실제 시각을 달고 오므로 결과가 이렇게 된다:
3185
3488
  * · **납기 초과를 영원히 보고하지 않는다** — 실 납기가 항상 미래로 보인다
3186
3489
  * · 폐기·도입예정 판정이 틀린다(§EffectivePeriod)
@@ -3217,14 +3520,14 @@ var FlowEngine = class {
3217
3520
  return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
3218
3521
  }
3219
3522
  /**
3220
- * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
3523
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 실행한다.**
3221
3524
  *
3222
3525
  * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
3223
- * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
3526
+ * 어긋난다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
3224
3527
  * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
3225
3528
  *
3226
3529
  * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
3227
- * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
3530
+ * (`hydrateObserved`)로 커널 상태에 주입한다. 그래서 관측으로 실행한 커널을 그대로 `fork`·`tick` 할 수
3228
3531
  * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
3229
3532
  *
3230
3533
  * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
@@ -3280,14 +3583,14 @@ var FlowEngine = class {
3280
3583
  return void 0;
3281
3584
  }
3282
3585
  /**
3283
- * **이 공장이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
3586
+ * **이 공장이 하루 몇 대를 낼 수 있는가** — 실행해 보지 않고 답한다.
3284
3587
  *
3285
3588
  * 커널이 직접 답하는 이유: 필요한 사실이 전부 여기 있다(공정 명세·설비와 신뢰도·인원·물리자산·
3286
3589
  * 자리·근무 달력·시각 기준). 밖에서 모으면 그 값을 옮겨 적게 되고, 한쪽만 바뀌는 순간 "충분하다"
3287
3590
  * 가 조용히 거짓이 된다. 계산 자체는 순수 함수(`analyzeCapacity`)에 맡긴다.
3288
3591
  *
3289
3592
  * 달력은 **자원이 선언한 것**을 쓴다. 자원마다 다른 달력을 쓰는 현장이면 대표를 고를 수 없으므로
3290
- * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 천장이 틀린 채로
3593
+ * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 상한이 틀린 채로
3291
3594
  * 그럴듯해 보인다.
3292
3595
  */
3293
3596
  capacity(opts) {
@@ -3375,7 +3678,7 @@ var FlowEngine = class {
3375
3678
  /**
3376
3679
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
3377
3680
  *
3378
- * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 상수를 이긴다.
3681
+ * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 고정한 상수를 이긴다.
3379
3682
  * 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
3380
3683
  * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
3381
3684
  */
@@ -3458,7 +3761,7 @@ var FlowEngine = class {
3458
3761
  else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
3459
3762
  }
3460
3763
  /**
3461
- * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 상수인가.**
3764
+ * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
3462
3765
  *
3463
3766
  * 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
3464
3767
  * "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
@@ -3554,7 +3857,7 @@ var FlowEngine = class {
3554
3857
  this.observeDisposition(epcs, DISP.reserved, bizStep);
3555
3858
  }
3556
3859
  /**
3557
- * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
3860
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 어긋난다.
3558
3861
  *
3559
3862
  * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
3560
3863
  * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
@@ -3808,7 +4111,7 @@ var FlowEngine = class {
3808
4111
  }
3809
4112
  return picked;
3810
4113
  }
3811
- /** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
4114
+ /** 확보한 자산을 작업에 배정한다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
3812
4115
  assignAssets(t, gear) {
3813
4116
  if (!gear.length) return;
3814
4117
  t.assets = gear;
@@ -3946,7 +4249,7 @@ var FlowEngine = class {
3946
4249
  * 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
3947
4250
  *
3948
4251
  * 작업은 혼자 서지 못한다: 옮길 **물품**과, (있다면) 그것을 시킨 **오더** 위에 선다. 진행 중에
3949
- * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 심지 않아서.
4252
+ * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 주입하지 않아서.
3950
4253
  *
3951
4254
  * 그때 도메인 훅은 없는 것을 딛으려다 던진다(`order.gtin` · `item.location`). 그 예외 하나가
3952
4255
  * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
@@ -4056,7 +4359,7 @@ var FlowEngine = class {
4056
4359
  * 자원 하나의 **가용 능력** — 계약의 판정에 이 커널의 시각·시간대·필수 시험을 채워 넘긴다.
4057
4360
  *
4058
4361
  * 배정도 스냅샷도 이 자리를 지난다. 예전에는 배정이 조건을 늘어놓고 화면이 플래그를 보고 짐작해서,
4059
- * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(놀고 있는 것은 맞지만 쓸 수 있는 것은 아니다).
4362
+ * **자격이 만료된 사람이 화면에서는 `대기` 로 보였다**(대기 중인 것은 맞지만 쓸 수 있는 것은 아니다).
4060
4363
  * 판정에 필요한 둘(시각·등급 상속)을 아는 것은 커널뿐이므로, 답도 커널이 낸다.
4061
4364
  */
4062
4365
  capabilityOfResource(r, directClassIds, defs, at = this.now()) {
@@ -4121,7 +4424,7 @@ var FlowEngine = class {
4121
4424
  }
4122
4425
  return picked.length ? picked : null;
4123
4426
  }
4124
- /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
4427
+ /** 확보한 사람을 작업에 배정한다(설비까지 확정된 뒤). */
4125
4428
  assignCrew(t, crew) {
4126
4429
  if (!crew.length) return;
4127
4430
  t.personnel = crew;
@@ -4172,7 +4475,7 @@ var FlowEngine = class {
4172
4475
  * 유효 기간 밖인가 — **네 번째 이유**(§Effectivity). 설비·사람·자산이 같은 규칙을 쓴다.
4173
4476
  *
4174
4477
  * 시뮬 시각을 ISO 로 풀어 계약의 `effectivityAt` 에 넘긴다. **판정은 커널에 두지 않는다** —
4175
- * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 갈라진다.
4478
+ * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 어긋난다.
4176
4479
  */
4177
4480
  effectivityOf(r) {
4178
4481
  if (!r.effectiveStart && !r.effectiveEnd) return void 0;
@@ -4193,7 +4496,7 @@ var FlowEngine = class {
4193
4496
  /**
4194
4497
  * 스냅샷·델타에 실을 조각 — **선언한 기간과 판정을 함께** 낸다.
4195
4498
  *
4196
- * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 갈라진다.
4499
+ * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 어긋난다.
4197
4500
  * 선언이 없으면 아무것도 붙이지 않는다(대부분의 자원이 그렇다 — 필드를 늘리지 않는다).
4198
4501
  */
4199
4502
  effectivePart(r) {
@@ -4209,7 +4512,7 @@ var FlowEngine = class {
4209
4512
  * 지금 근무 시간 밖인가 — **캘린더가 있으면 그것으로, 없으면 옛 `window` 로** 판정한다.
4210
4513
  *
4211
4514
  * `window`(시 단위 하나)는 캘린더의 특수한 경우다. 둘을 한 함수로 모아 두면 인원·설비가 같은 규칙을
4212
- * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 갈라진다).
4515
+ * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 어긋난다).
4213
4516
  */
4214
4517
  offCalendar(r) {
4215
4518
  return offCalendarAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
@@ -4680,7 +4983,7 @@ var WmsKernel = class extends FlowEngine {
4680
4983
  * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
4681
4984
  * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
4682
4985
  *
4683
- * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
4986
+ * **한 오더에 사슬 하나만** 실행한다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
4684
4987
  * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
4685
4988
  */
4686
4989
  makeShortLines(o) {
@@ -4701,7 +5004,7 @@ var WmsKernel = class extends FlowEngine {
4701
5004
  if (plan.steps.length) return;
4702
5005
  }
4703
5006
  }
4704
- /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
5007
+ /** 이 오더가 실행 중인 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
4705
5008
  hasOpenMakeChain(orderId) {
4706
5009
  for (const t of this.tasks.values()) {
4707
5010
  if (t.orderId !== orderId || t.status === "completed") continue;
@@ -4748,7 +5051,7 @@ var WmsKernel = class extends FlowEngine {
4748
5051
  * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
4749
5052
  *
4750
5053
  * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
4751
- * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
5054
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 고정되어 있다(`품목@자리`). 창고의 출고 경로는
4752
5055
  * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
4753
5056
  * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
4754
5057
  * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
@@ -5020,7 +5323,7 @@ var MesKernel = class extends FlowEngine {
5020
5323
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
5021
5324
  *
5022
5325
  * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
5023
- * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
5326
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 구분한다) · 생산오더 연결. 코어의 일반
5024
5327
  * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
5025
5328
  *
5026
5329
  * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
@@ -5114,7 +5417,7 @@ var MesKernel = class extends FlowEngine {
5114
5417
  /**
5115
5418
  * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
5116
5419
  *
5117
- * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
5420
+ * 씨앗이 이미 이행된 오더를 주입하지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
5118
5421
  * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
5119
5422
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
5120
5423
  */
@@ -5270,6 +5573,445 @@ var MesKernel = class extends FlowEngine {
5270
5573
  }
5271
5574
  };
5272
5575
 
5576
+ // src/ems-kernel.ts
5577
+ var DEMAND_WINDOW_MS = 15 * 60 * 1e3;
5578
+ var demandWindowStart = (atMs, windowMs = DEMAND_WINDOW_MS) => Math.floor(atMs / windowMs) * windowMs;
5579
+ function windowMsOf(opts) {
5580
+ const raw = typeof opts === "number" ? opts : opts?.windowMs;
5581
+ const n = Number(raw);
5582
+ return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
5583
+ }
5584
+ var KEEP_CLOSED = 96;
5585
+ var EmsKernel = class extends FlowEngine {
5586
+ points = /* @__PURE__ */ new Map();
5587
+ open;
5588
+ closed = [];
5589
+ closedTotal = 0;
5590
+ peak;
5591
+ /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
5592
+ suggestedFor;
5593
+ windowMs;
5594
+ /**
5595
+ * ── 세 번째 인자는 **호스트가 정한 자리**다 (2026-08-14 실측으로 고침) ──────
5596
+ *
5597
+ * 호스트는 모든 커널을 한 모양으로 세운다: `new Kernel(tenantId, undefined, productionSpecOf(model))`.
5598
+ * 즉 세 번째 인자는 **도메인 옵션 슬롯**이고 커널마다 뜻이 다르다(야드는 모드, 생산은 명세).
5599
+ *
5600
+ * 처음에 이 자리를 `windowMs: number` 로 받았다가, 라이브에서 **모든 수요 구간이 깨졌다** —
5601
+ * 생산 명세 객체가 창 길이로 들어와 `startMs: null`·`endMs: NaN` 이 됐다. 내 시험은 커널을
5602
+ * `new EmsKernel('t')` 로 직접 세웠기 때문에 그것을 잡지 못했다(호스트와 다른 방식으로 세운 것이
5603
+ * 그 자체로 결함이었다).
5604
+ *
5605
+ * 그래서 **쓸 수 있는 것만 읽는다**: 수면 창 길이로 쓰고, 객체면 `windowMs` 를 찾고, 없으면 기본값
5606
+ * (15분)이다. 모르는 것을 창 길이로 삼지 않는다.
5607
+ */
5608
+ constructor(tenantId, policy = firstFitPolicy, opts) {
5609
+ super(tenantId, policy);
5610
+ this.windowMs = windowMsOf(opts);
5611
+ }
5612
+ /**
5613
+ * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
5614
+ *
5615
+ * 여러 자리가 선언하면 **가장 큰 값**을 쓴다: 수전 지점의 계약이 분기의 것보다 크고, 트윈 전체의
5616
+ * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
5617
+ * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
5618
+ */
5619
+ declaredContractKW() {
5620
+ let max;
5621
+ for (const loc of this.boardDef?.locations ?? []) {
5622
+ for (const p of loc.properties ?? []) {
5623
+ if (p.id !== EMS_PROPERTY.contractKW) continue;
5624
+ const v = Number(p.value);
5625
+ if (!Number.isFinite(v) || v <= 0) continue;
5626
+ if (max === void 0 || v > max) max = v;
5627
+ }
5628
+ }
5629
+ return max;
5630
+ }
5631
+ /**
5632
+ * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
5633
+ *
5634
+ * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
5635
+ * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
5636
+ */
5637
+ apply(envelope) {
5638
+ if (envelope.eventType === ENERGY_EVENT.measured) {
5639
+ this.ingestMeasured(envelope);
5640
+ return;
5641
+ }
5642
+ super.apply(envelope);
5643
+ }
5644
+ ingestMeasured(envelope) {
5645
+ const d = envelope.data;
5646
+ const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
5647
+ if (atMs === void 0) throw new Error("energy.measured has no usable time (data.at / eventTime) \u2014 cannot place it in a demand window");
5648
+ const id = String(d?.meterId ?? "").trim();
5649
+ if (!id) throw new Error("energy.measured has no meterId \u2014 a measurement with no meter cannot be accumulated");
5650
+ this.closeDue(atMs);
5651
+ this.openWindow(atMs);
5652
+ const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
5653
+ const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
5654
+ if (point.atMs === void 0 || atMs >= point.atMs) {
5655
+ point.atMs = atMs;
5656
+ if (kW !== void 0) point.kW = kW;
5657
+ if (Number.isFinite(Number(d?.kWh))) point.kWh = Number(d.kWh);
5658
+ if (Number.isFinite(Number(d?.powerFactor))) point.powerFactor = Number(d.powerFactor);
5659
+ }
5660
+ point.samplesInWindow++;
5661
+ this.points.set(id, point);
5662
+ const w = this.open;
5663
+ if (kW !== void 0) {
5664
+ const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
5665
+ if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
5666
+ w.samples++;
5667
+ w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
5668
+ }
5669
+ this.revision++;
5670
+ this.judgeOpenWindow(atMs);
5671
+ }
5672
+ openWindow(atMs) {
5673
+ const start = demandWindowStart(atMs, this.windowMs);
5674
+ if (this.open?.startMs === start) return;
5675
+ const contractKW = this.declaredContractKW();
5676
+ this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...contractKW !== void 0 ? { contractKW } : {} };
5677
+ for (const p of this.points.values()) p.samplesInWindow = 0;
5678
+ this.suggestedFor = void 0;
5679
+ }
5680
+ /**
5681
+ * 지난 구간을 닫는다 — **표본이 없으면 값을 만들지 않는다.**
5682
+ *
5683
+ * 침묵한 구간을 「0 kW」로 닫으면 그 트윈은 「그 15분 동안 전기를 쓰지 않았다」고 말하는 것이 된다.
5684
+ * 구간이 지난 것은 사실이고 우리가 못 쟀다는 것도 사실이므로, 구간은 남기고 값은 비운다.
5685
+ *
5686
+ * 표본이 오지 않으면 마감도 오지 않는다(라이브에서 계측이 끊기면 열린 구간이 그대로 남는다).
5687
+ * 그래서 이것은 **공개**다 — 호스트가 시각을 주며 부를 수 있다(`tick` 도 이것을 부른다).
5688
+ */
5689
+ closeDue(nowMs) {
5690
+ const done = [];
5691
+ while (this.open && nowMs >= this.open.endMs) {
5692
+ const w = this.open;
5693
+ if (w.contractKW !== void 0 && w.maxKW !== void 0) w.overContract = w.maxKW > w.contractKW;
5694
+ this.closed.push(w);
5695
+ this.closedTotal++;
5696
+ if (this.closed.length > KEEP_CLOSED) this.closed.splice(0, this.closed.length - KEEP_CLOSED);
5697
+ done.push(w);
5698
+ this.emitOp(ENERGY_EVENT.demandWindow, {
5699
+ startMs: w.startMs,
5700
+ endMs: w.endMs,
5701
+ ...w.maxKW !== void 0 ? { maxKW: w.maxKW } : {},
5702
+ ...w.meanKW !== void 0 ? { meanKW: w.meanKW } : {},
5703
+ samples: w.samples,
5704
+ ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
5705
+ ...w.overContract !== void 0 ? { overContract: w.overContract } : {},
5706
+ /*
5707
+ * 부하를 셀 근거가 없었다는 사실을 코드로 낸다 — 화면이 「0 kW」와 「못 쟀다」를 구별할 수 있게.
5708
+ *
5709
+ * 이름이 조건과 정확히 같아야 한다: `samples` 는 **kW 를 실은 표본**의 수다. 표본은 왔는데
5710
+ * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
5711
+ * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
5712
+ */
5713
+ ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {}
5714
+ });
5715
+ if (w.maxKW !== void 0 && (this.peak === void 0 || w.maxKW > this.peak.kW)) {
5716
+ this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
5717
+ this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {} });
5718
+ }
5719
+ this.open = void 0;
5720
+ }
5721
+ return done;
5722
+ }
5723
+ /**
5724
+ * 열린 구간의 판정 — **이대로 가면 계약을 넘는가.**
5725
+ *
5726
+ * 예측은 「지금까지의 평균 부하가 구간 끝까지 이어진다」다. 단순하지만 그 가정을 **값과 함께 낸다**
5727
+ * (`projectionBasis`) — 근거를 감춘 예측은 사용자가 검증할 수 없다. 남은 시간이 짧을수록 이 예측은
5728
+ * 실제에 가까워진다(구간 초반의 경보는 성급할 수 있다는 뜻이고, 그것도 사용자가 알아야 한다).
5729
+ *
5730
+ * 넘을 것 같으면 **제안**을 낸다 — 무엇을 줄일 수 있는지는 모델이 선언한 감축 가능 설비가 답한다.
5731
+ * 우리는 끄지 않는다.
5732
+ */
5733
+ judgeOpenWindow(atMs) {
5734
+ const w = this.open;
5735
+ if (!w || w.contractKW === void 0 || w.meanKW === void 0) return;
5736
+ if (this.suggestedFor === w.startMs) return;
5737
+ const elapsed = Math.max(1, atMs - w.startMs);
5738
+ const projected = (w.meanKW * elapsed + w.meanKW * (w.endMs - atMs)) / this.windowMs;
5739
+ if (projected <= w.contractKW) return;
5740
+ this.suggestedFor = w.startMs;
5741
+ this.emitOp(ENERGY_EVENT.drSuggested, {
5742
+ windowStartMs: w.startMs,
5743
+ projectedKW: projected,
5744
+ contractKW: w.contractKW,
5745
+ gapKW: projected - w.contractKW,
5746
+ projectionBasis: "mean-so-far",
5747
+ /* 줄일 수 있는 것 — 선언된 감축 가능 설비다. 우선순위·최소 유지는 현장이 정하므로 여기서
5748
+ 고르지 않고 **후보를 있는 대로** 낸다(고르는 것은 사람의 일이다). */
5749
+ curtailableCandidates: this.curtailableIds()
5750
+ });
5751
+ }
5752
+ /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
5753
+ curtailableIds() {
5754
+ return (this.boardDef?.equipment ?? []).filter((e) => String(e.kind ?? "") === "curtailable-load").map((e) => e.id);
5755
+ }
5756
+ /*
5757
+ * ── 흐름 훅 넷 — **상속의 대가다** (§6 안 A) ─────────────────────────────
5758
+ *
5759
+ * `FlowEngine` 은 이 넷을 abstract 로 요구한다: 도착·오더·배정·작업 완료. 에너지에는 그 넷이 없다
5760
+ * (물건이 도착하지 않고, 주문이 없고, 배정할 자원이 없고, 완료될 작업이 없다). 그래서 여기서
5761
+ * **아무 일도 하지 않는다** — 그런데 조용히 넘기지 않는다: 이 커널이 그런 요청을 받았다는 것은
5762
+ * **배선이 잘못됐다는 사실**이고(EMS 트윈에 물류 명령을 보낸 것), 조용히 넘기면 그 사실이 사라진다.
5763
+ *
5764
+ * 던지지도 않는다: 저널 재생 중이라면 트윈 전체가 멈춘다. 그래서 커널이 담을 줄 모르는 사건을
5765
+ * 세는 자리(`ObservedReducer.unhandled`)와 같은 규율으로, **개수를 세어 상태로 낸다.**
5766
+ */
5767
+ flowRequests = /* @__PURE__ */ new Map();
5768
+ noteFlowRequest(hook) {
5769
+ this.flowRequests.set(hook, (this.flowRequests.get(hook) ?? 0) + 1);
5770
+ }
5771
+ onArrival() {
5772
+ this.noteFlowRequest("onArrival");
5773
+ }
5774
+ onOrder() {
5775
+ this.noteFlowRequest("onOrder");
5776
+ }
5777
+ allocate() {
5778
+ this.noteFlowRequest("allocate");
5779
+ }
5780
+ onTaskComplete() {
5781
+ this.noteFlowRequest("onTaskComplete");
5782
+ }
5783
+ /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
5784
+ tick(dtMs) {
5785
+ super.tick(dtMs);
5786
+ this.closeDue(this.clockMs);
5787
+ }
5788
+ getSnapshot() {
5789
+ const snap = super.getSnapshot();
5790
+ const contractKW = this.declaredContractKW();
5791
+ const energy = {
5792
+ points: [...this.points.values()].map((p) => ({ ...p })),
5793
+ ...this.open ? {
5794
+ open: {
5795
+ ...this.open,
5796
+ ...this.open.meanKW !== void 0 ? { projectedKW: this.open.meanKW, projectionBasis: "mean-so-far" } : {}
5797
+ }
5798
+ } : {},
5799
+ closed: this.closed.map((w) => ({ ...w })),
5800
+ closedTotal: this.closedTotal,
5801
+ ...this.peak ? { peakSince: { ...this.peak } } : {},
5802
+ ...contractKW !== void 0 ? { contractKW } : {},
5803
+ /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
5804
+ ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
5805
+ };
5806
+ return { ...snap, energy };
5807
+ }
5808
+ };
5809
+
5810
+ // src/energy-ingest.ts
5811
+ function isEnergyRecord(record) {
5812
+ if (!record || typeof record !== "object") return false;
5813
+ const r = record;
5814
+ return typeof r.meterId === "string" && r.meterId.trim().length > 0 && r.epc === void 0;
5815
+ }
5816
+ function ingestEnergyRecords(records, opts) {
5817
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
5818
+ const accepted = [];
5819
+ const rejected = [];
5820
+ let seq = 0;
5821
+ for (const record of arr) {
5822
+ const errors = [];
5823
+ const meterId = String(record?.meterId ?? "").trim();
5824
+ if (!meterId) errors.push("meterId \uC5C6\uC74C \u2014 \uC5B4\uB514\uC758 \uC18C\uBE44\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uB204\uC801\uD560 \uC218 \uC5C6\uB2E4");
5825
+ const at = String(record?.at ?? "").trim() || opts.defaultEventTime;
5826
+ const atMs = at ? Date.parse(at) : Number.NaN;
5827
+ if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uB0A8\uC758 \uC218\uC694 \uAD6C\uAC04\uC5D0 \uC2E4\uB9B0\uB2E4");
5828
+ const num = (v, name) => {
5829
+ if (v === void 0 || v === null || v === "") return void 0;
5830
+ const n = Number(v);
5831
+ if (!Number.isFinite(n)) {
5832
+ errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
5833
+ return void 0;
5834
+ }
5835
+ return n;
5836
+ };
5837
+ const kW = num(record?.kW, "kW");
5838
+ const kWh = num(record?.kWh, "kWh");
5839
+ const powerFactor = num(record?.powerFactor, "powerFactor");
5840
+ if (errors.length) {
5841
+ rejected.push({ record, errors });
5842
+ continue;
5843
+ }
5844
+ const eventTime = new Date(atMs).toISOString();
5845
+ const data = {
5846
+ meterId,
5847
+ /* 계약은 `kW` 를 필수로 두지만 **못 읽은 표본도 사실**이다 — 그 경우 값을 비우고 보낸다.
5848
+ 커널이 「받았지만 부하를 못 읽었다」로 세고, 구간 마감에 그 이유를 싣는다. */
5849
+ ...kW !== void 0 ? { kW } : {},
5850
+ ...kWh !== void 0 ? { kWh } : {},
5851
+ ...powerFactor !== void 0 ? { powerFactor } : {},
5852
+ at: eventTime
5853
+ };
5854
+ accepted.push({
5855
+ eventId: `${opts.tenantId}-energy-${++seq}`,
5856
+ eventType: ENERGY_EVENT.measured,
5857
+ eventTime,
5858
+ tenantId: opts.tenantId,
5859
+ data
5860
+ });
5861
+ }
5862
+ return { accepted, rejected };
5863
+ }
5864
+
5865
+ // src/energy-attribution.ts
5866
+ var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
5867
+ function attributeEnergy(opts) {
5868
+ const byId = new Map(opts.consumers.map((c) => [c.id, c]));
5869
+ const dedicated = /* @__PURE__ */ new Map();
5870
+ for (const c of opts.consumers) if (c.meterId) dedicated.set(c.meterId, c);
5871
+ const shares = [];
5872
+ const unattributed = [];
5873
+ const excluded = [];
5874
+ const overhead = [];
5875
+ for (const pool of opts.pools) {
5876
+ const kWh = Number(pool.kWh);
5877
+ if (!Number.isFinite(kWh)) continue;
5878
+ const own = dedicated.get(pool.meterId);
5879
+ if (own) {
5880
+ shares.push({ consumerId: own.id, kWh, basis: "measured", poolMeterId: pool.meterId });
5881
+ continue;
5882
+ }
5883
+ if (pool.overhead) {
5884
+ const alloc = opts.overheadAllocation;
5885
+ const targets = (alloc?.processConsumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
5886
+ const wOf = (c) => alloc?.weightKind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
5887
+ const sharing2 = alloc?.weightKind === "equal" ? targets : targets.filter((c) => wOf(c) > 0);
5888
+ const ws = sharing2.map(wOf);
5889
+ const total = ws.reduce((a, b) => a + b, 0);
5890
+ if (!alloc || total <= 0) {
5891
+ overhead.push({ poolMeterId: pool.meterId, kWh });
5892
+ continue;
5893
+ }
5894
+ let done = 0;
5895
+ sharing2.forEach((c, i) => {
5896
+ const share = ws[i] / total;
5897
+ const amount = i === sharing2.length - 1 ? kWh - done : kWh * share;
5898
+ done += amount;
5899
+ shares.push({
5900
+ consumerId: c.id,
5901
+ kWh: amount,
5902
+ basis: "apportioned",
5903
+ poolMeterId: pool.meterId,
5904
+ weightKind: alloc.weightKind,
5905
+ weightShare: share,
5906
+ overhead: true
5907
+ });
5908
+ });
5909
+ continue;
5910
+ }
5911
+ const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
5912
+ if (!covered.length) {
5913
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: "no-consumers" });
5914
+ continue;
5915
+ }
5916
+ if (covered.length === 1) {
5917
+ shares.push({ consumerId: covered[0].id, kWh, basis: "measured", poolMeterId: pool.meterId });
5918
+ continue;
5919
+ }
5920
+ const kind = opts.weightKind;
5921
+ const weightOf = (c) => kind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
5922
+ const sharing = kind === "equal" ? covered : covered.filter((c) => weightOf(c) > 0);
5923
+ if (kind && sharing.length < covered.length) {
5924
+ for (const c of covered) if (weightOf(c) <= 0) excluded.push({ consumerId: c.id, poolMeterId: pool.meterId, reason: "zero-weight" });
5925
+ }
5926
+ const weights = sharing.map(weightOf);
5927
+ const sum = weights.reduce((a, b) => a + b, 0);
5928
+ if (!kind || sum <= 0) {
5929
+ unattributed.push({
5930
+ poolMeterId: pool.meterId,
5931
+ kWh,
5932
+ reason: "no-weights",
5933
+ consumerIds: covered.map((c) => c.id)
5934
+ });
5935
+ continue;
5936
+ }
5937
+ let given = 0;
5938
+ sharing.forEach((c, i) => {
5939
+ const share = weights[i] / sum;
5940
+ const amount = i === sharing.length - 1 ? kWh - given : kWh * share;
5941
+ given += amount;
5942
+ shares.push({
5943
+ consumerId: c.id,
5944
+ kWh: amount,
5945
+ basis: "apportioned",
5946
+ poolMeterId: pool.meterId,
5947
+ weightKind: kind,
5948
+ weightShare: share
5949
+ });
5950
+ });
5951
+ }
5952
+ const measuredKWh = opts.pools.reduce((a, p) => a + (Number.isFinite(Number(p.kWh)) ? Number(p.kWh) : 0), 0);
5953
+ const attributedKWh = shares.reduce((a, s) => a + s.kWh, 0);
5954
+ const unattributedKWh = unattributed.reduce((a, u) => a + u.kWh, 0);
5955
+ const overheadKWh = overhead.reduce((a, o) => a + o.kWh, 0);
5956
+ if (!near(attributedKWh + unattributedKWh + overheadKWh, measuredKWh, 1e-6)) {
5957
+ throw new Error(
5958
+ `energy attribution lost or created energy: measured=${measuredKWh} attributed=${attributedKWh} unattributed=${unattributedKWh} overhead=${overheadKWh}`
5959
+ );
5960
+ }
5961
+ return { shares, unattributed, excluded, overhead, totals: { measuredKWh, attributedKWh, unattributedKWh, overheadKWh } };
5962
+ }
5963
+ var UNIT = {
5964
+ output: "kWh/unit",
5965
+ runtimeHours: "kW",
5966
+ area: "kWh/m2"
5967
+ };
5968
+ function energyIntensity(input) {
5969
+ const kWh = Number(input.kWh);
5970
+ if (!Number.isFinite(kWh)) return { value: null, reason: "no-energy" };
5971
+ const den = Number(input.denominator?.value);
5972
+ if (!Number.isFinite(den)) return { value: null, reason: "no-denominator" };
5973
+ const ew = input.energyWindow;
5974
+ const dw = input.denominator?.window;
5975
+ if (ew && dw && (ew.startMs !== dw.startMs || ew.endMs !== dw.endMs)) return { value: null, reason: "window-mismatch" };
5976
+ if (den === 0) return { value: null, reason: "zero-denominator" };
5977
+ const window = ew ?? dw;
5978
+ return {
5979
+ value: kWh / den,
5980
+ unit: UNIT[input.denominator.kind] ?? "kWh",
5981
+ kWh,
5982
+ denominator: den,
5983
+ ...window ? { window } : { window: { startMs: 0, endMs: 0 } }
5984
+ };
5985
+ }
5986
+ function energyOfWindows(windows, range) {
5987
+ const inRange = (w) => !range || w.startMs >= range.startMs && w.endMs <= range.endMs;
5988
+ let kWh = 0;
5989
+ let counted = 0;
5990
+ let skipped = 0;
5991
+ let first;
5992
+ let last;
5993
+ for (const w of windows ?? []) {
5994
+ if (!inRange(w)) continue;
5995
+ const mean = Number(w.meanKW);
5996
+ const hours = (Number(w.endMs) - Number(w.startMs)) / 36e5;
5997
+ if (!Number.isFinite(mean) || !Number.isFinite(hours) || hours <= 0) {
5998
+ skipped++;
5999
+ continue;
6000
+ }
6001
+ kWh += mean * hours;
6002
+ counted++;
6003
+ if (first === void 0 || w.startMs < first) first = w.startMs;
6004
+ if (last === void 0 || w.endMs > last) last = w.endMs;
6005
+ }
6006
+ return {
6007
+ ...counted > 0 ? { kWh } : {},
6008
+ basis: "mean-kw",
6009
+ counted,
6010
+ skipped,
6011
+ ...first !== void 0 && last !== void 0 ? { window: { startMs: first, endMs: last } } : {}
6012
+ };
6013
+ }
6014
+
5273
6015
  // src/vocabulary.ts
5274
6016
  var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
5275
6017
  var VOCABULARY_EXCEPTIONS = [
@@ -5322,11 +6064,18 @@ function retiredVocabularyIn(line) {
5322
6064
  CAPABILITY_KEYS,
5323
6065
  CBV_BIZSTEP,
5324
6066
  CMD,
6067
+ DEMAND_WINDOW_MS,
5325
6068
  DISP,
5326
6069
  DOMAIN_CATALOG,
5327
6070
  DOMAIN_SYSTEMS,
6071
+ EMS_EQUIPMENT_TYPES,
6072
+ EMS_LOCATION_TYPES,
6073
+ EMS_PROPERTY,
6074
+ EMS_TYPES,
6075
+ ENERGY_EVENT,
5328
6076
  EPCIS_CONTEXT,
5329
6077
  EQUIPMENT_LEVEL,
6078
+ EmsKernel,
5330
6079
  EventJournal,
5331
6080
  FlowEngine,
5332
6081
  GUARD_PRAGMA,
@@ -5365,6 +6114,9 @@ function retiredVocabularyIn(line) {
5365
6114
  activeShiftOf,
5366
6115
  aggregationEvent,
5367
6116
  analyzeCapacity,
6117
+ attributeEnergy,
6118
+ axesOfSystem,
6119
+ axisAppliesTo,
5368
6120
  axisInfo,
5369
6121
  axisSource,
5370
6122
  capabilitiesForType,
@@ -5375,10 +6127,13 @@ function retiredVocabularyIn(line) {
5375
6127
  constantDuration,
5376
6128
  conversionFactorOf,
5377
6129
  counterfactualAt,
6130
+ demandWindowStart,
5378
6131
  deriveAttentions,
5379
6132
  documentPath,
5380
6133
  dueStatusOf,
5381
6134
  effectivityAt,
6135
+ energyIntensity,
6136
+ energyOfWindows,
5382
6137
  fefoPolicy,
5383
6138
  firstFitPolicy,
5384
6139
  foldJobResponses,
@@ -5389,9 +6144,12 @@ function retiredVocabularyIn(line) {
5389
6144
  inWorkCalendar,
5390
6145
  inWorkCalendarAt,
5391
6146
  ingest,
6147
+ ingestEnergyRecords,
6148
+ isEnergyRecord,
5392
6149
  isEquipmentLevel,
5393
6150
  isoDurationHours,
5394
6151
  itemKeyOf,
6152
+ levelOfLocationType,
5395
6153
  lgtinClass,
5396
6154
  locationStatusOf,
5397
6155
  mapRecord,