@operato/twin-kernel 0.7.1 → 0.7.3

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.
@@ -1,4 +1,4 @@
1
- export type CapabilityKey = 'operable' | 'storable' | 'mobile' | 'processable' | 'trackable' | 'metered' | 'curtailable' | 'generating' | 'storing';
1
+ export type CapabilityKey = 'operable' | 'storable' | 'mobile' | 'processable' | 'trackable' | 'switching' | 'metered' | 'curtailable' | 'generating' | 'storing';
2
2
  /** 이동 관측 — Mobile 이 노출. */
3
3
  export interface Motion {
4
4
  fromNode: string;
@@ -58,6 +58,15 @@ export interface StoringState {
58
58
  chargeKW?: number;
59
59
  dischargeKW?: number;
60
60
  }
61
+ /**
62
+ * 개폐 위치 — IEC 61850 `XCBR.Pos`·`XSWI.Pos` 의 넷 값을 그대로 쓴다.
63
+ *
64
+ * `intermediate`(과도)와 `bad`(위치 불량)를 **뭉개지 않는다**: 실 계통에서 이 둘은 이중 접점이 서로
65
+ * 다른 말을 하는 상태이고, 「열림/닫힘」으로 반올림하면 트윈이 없는 확신을 갖는다.
66
+ */
67
+ export interface SwitchingState {
68
+ position?: 'open' | 'closed' | 'intermediate' | 'bad';
69
+ }
61
70
  /** 능력 관측 계약 메타(발견·검증). ops 없음(원칙 ①). */
62
71
  export interface CapabilitySpec {
63
72
  key: CapabilityKey;
@@ -33,6 +33,22 @@ export const CAPABILITIES = {
33
33
  key: 'processable', label: 'twin.capability.processable', semantics: '변환/가공 수행 — 산출(양품/불량). 운영 status 는 Operable 조합. progress 방출은 후속.',
34
34
  stateFields: ['output'], results: ['completed']
35
35
  },
36
+ /*
37
+ * ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
38
+ * 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
39
+ * (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
40
+ * **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
41
+ *
42
+ * 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
43
+ * `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
44
+ * 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
45
+ *
46
+ * `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
47
+ */
48
+ switching: {
49
+ key: 'switching', label: 'twin.capability.switching', semantics: '회로를 열고 닫는 기기의 **위치를 읽는다** — 우리는 조작하지 않는다(명령 없음). IEC 61850 XCBR/XSWI Pos.',
50
+ stateFields: ['position'], models: ['SwitchingState'], results: ['positionChanged']
51
+ },
36
52
  trackable: {
37
53
  key: 'trackable', label: 'twin.capability.trackable', semantics: '오더/아이템 생애 추적 — 생애단계(도메인 라벨, 무방언)·진행·보류.',
38
54
  stateFields: ['lifecycle', 'progress', 'held'], results: ['lifecycleChanged']
@@ -60,7 +76,7 @@ export const CAPABILITIES = {
60
76
  stateFields: ['soc', 'chargeKW', 'dischargeKW'], models: ['StoringState'], invariants: ['0 <= soc <= 100'], results: ['stored', 'discharged']
61
77
  }
62
78
  };
63
- export const CAPABILITY_KEYS = ['operable', 'storable', 'mobile', 'processable', 'trackable', 'metered', 'curtailable', 'generating', 'storing'];
79
+ export const CAPABILITY_KEYS = ['operable', 'storable', 'mobile', 'processable', 'trackable', 'switching', 'metered', 'curtailable', 'generating', 'storing'];
64
80
  /** 능력 집합이 관측 노출하는 상태 필드 합집합(직교이므로 단순 병합). */
65
81
  export function stateFieldsOf(caps) {
66
82
  const out = new Set();
@@ -39,6 +39,14 @@ export declare class EmsKernel extends FlowEngine {
39
39
  * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
40
40
  */
41
41
  private declaredContractKW;
42
+ /**
43
+ * **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
44
+ *
45
+ * 계층 계량(수전 ⊃ 분기)에서 전부 더하면 이중 계상이 된다. 뿌리만 더하면 현장의 실제 부하가 된다.
46
+ * 모델이 그 계량기의 자리를 모르면 뿌리로 본다(모르는 것을 빼면 부하가 조용히 작아진다 — 그것이
47
+ * 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
48
+ */
49
+ private rootMeterIds;
42
50
  /**
43
51
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
44
52
  *
@@ -93,6 +93,51 @@ export class EmsKernel extends FlowEngine {
93
93
  }
94
94
  return max;
95
95
  }
96
+ /**
97
+ * **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
98
+ *
99
+ * 계층 계량(수전 ⊃ 분기)에서 전부 더하면 이중 계상이 된다. 뿌리만 더하면 현장의 실제 부하가 된다.
100
+ * 모델이 그 계량기의 자리를 모르면 뿌리로 본다(모르는 것을 빼면 부하가 조용히 작아진다 — 그것이
101
+ * 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
102
+ */
103
+ rootMeterIds() {
104
+ const locOf = new Map();
105
+ for (const e of this.boardDef?.equipment ?? [])
106
+ locOf.set(e.id, e.homeLocation);
107
+ const parentOf = new Map();
108
+ for (const l of this.boardDef?.locations ?? [])
109
+ parentOf.set(l.id, l.parentId);
110
+ /* 계량되고 있는 자리들 — 표본이 실제로 온 계량기의 자리만 센다(선언만 있고 값이 없는 계량기는
111
+ 부하에 기여하지 않으므로 계층 판정에서도 제외한다). */
112
+ const meteredLocs = new Set();
113
+ for (const id of this.points.keys()) {
114
+ const loc = locOf.get(id);
115
+ if (loc)
116
+ meteredLocs.add(loc);
117
+ }
118
+ const out = new Set();
119
+ for (const id of this.points.keys()) {
120
+ const loc = locOf.get(id);
121
+ if (!loc) {
122
+ out.add(id);
123
+ continue;
124
+ } // 자리를 모르면 뿌리로 본다
125
+ let cur = parentOf.get(loc);
126
+ let root = true;
127
+ const seen = new Set([loc]);
128
+ while (cur && !seen.has(cur)) {
129
+ if (meteredLocs.has(cur)) {
130
+ root = false;
131
+ break;
132
+ }
133
+ seen.add(cur);
134
+ cur = parentOf.get(cur);
135
+ }
136
+ if (root)
137
+ out.add(id);
138
+ }
139
+ return out;
140
+ }
96
141
  /**
97
142
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
98
143
  *
@@ -138,12 +183,21 @@ export class EmsKernel extends FlowEngine {
138
183
  const w = this.open;
139
184
  if (kW !== undefined) {
140
185
  /*
141
- * 구간의 부하는 **지점의 합**이다(수전 하나만 계량하는 현장도, 피더를 여럿 계량하는 현장도 있다).
142
- * 마지막으로 관측된 값들을 더한다 — 표본 주기가 지점마다 달라도 이것이 그 순간의 최선이다.
143
- * 지점 하나가 침묵하면 값은 낡은 채로 더해진다: 그래서 `atMs` 지점마다 남겨 소비처가
144
- * 「이 지점은 3시간째 조용하다」를 있게 한다.
186
+ * ── 구간의 부하는 **뿌리 계량기의 합**이다 (2026-08-14 실측으로 고침) ────────
187
+ *
188
+ * 처음에는 「지점의 합」이라고만 했다. 그런데 실제 현장은 **계층 계량**이다: 수전 계량기가 공장
189
+ * 전체를 재고, 분기 계량기가 안의 라인을 나눠 잰다. 둘을 다 더하면 **이중 계상**이 되고,
190
+ * 목 데이터로 재 보니 실제 3,855kW 인 현장이 7,414kW 로 읽혀 **계약(4,000kW)을 넘었다는 거짓
191
+ * 판정**이 나왔다. 요금은 수전 지점에서 매겨지므로 그 값이 사실이다.
192
+ *
193
+ * 그래서 **뿌리만 더한다**: 자기 자리의 조상 중에 계량된 자리가 없는 계량기가 뿌리다. 수전만
194
+ * 계량하는 현장은 그것 하나, 분기만 계량하는 현장은 분기들의 합, 둘 다 계량하면 수전 하나.
195
+ *
196
+ * 모델이 그 계량기를 모르면(설비 선언에 없다) 뿌리로 본다 — 모르는 것을 빼면 그만큼 부하가
197
+ * 조용히 작아지고, 작아진 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
145
198
  */
146
- const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
199
+ const roots = this.rootMeterIds();
200
+ const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
147
201
  if (w.maxKW === undefined || total > w.maxKW)
148
202
  w.maxKW = total;
149
203
  w.samples++;
@@ -69,11 +69,15 @@ export const EMS_TYPES = [
69
69
  label: 'twin.type.breaker',
70
70
  /*
71
71
  * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
72
- * 상태를 읽어 계통 구성을 알 뿐이다 — 그래서 능력은 `operable` 만이고 `curtailable` 이 아니다.
72
+ * 상태를 읽어 계통 구성을 알 뿐이다.
73
+ *
74
+ * 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
75
+ * **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
76
+ * 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
73
77
  */
74
78
  standardClass: { iec61850: 'XCBR', iso55000: 'Asset' },
75
79
  identity: { scheme: 'kernel:id' },
76
- capabilities: ['operable']
80
+ capabilities: ['switching']
77
81
  },
78
82
  {
79
83
  key: 'pv-array',
@@ -1665,11 +1665,15 @@ var EMS_TYPES = [
1665
1665
  label: "twin.type.breaker",
1666
1666
  /*
1667
1667
  * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
1668
- * 상태를 읽어 계통 구성을 알 뿐이다 — 그래서 능력은 `operable` 만이고 `curtailable` 이 아니다.
1668
+ * 상태를 읽어 계통 구성을 알 뿐이다.
1669
+ *
1670
+ * 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
1671
+ * **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
1672
+ * 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
1669
1673
  */
1670
1674
  standardClass: { iec61850: "XCBR", iso55000: "Asset" },
1671
1675
  identity: { scheme: "kernel:id" },
1672
- capabilities: ["operable"]
1676
+ capabilities: ["switching"]
1673
1677
  },
1674
1678
  {
1675
1679
  key: "pv-array",
@@ -1760,6 +1764,26 @@ var CAPABILITIES = {
1760
1764
  stateFields: ["output"],
1761
1765
  results: ["completed"]
1762
1766
  },
1767
+ /*
1768
+ * ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
1769
+ * 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
1770
+ * (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
1771
+ * **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
1772
+ *
1773
+ * 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
1774
+ * `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
1775
+ * 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
1776
+ *
1777
+ * `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
1778
+ */
1779
+ switching: {
1780
+ key: "switching",
1781
+ label: "twin.capability.switching",
1782
+ semantics: "\uD68C\uB85C\uB97C \uC5F4\uACE0 \uB2EB\uB294 \uAE30\uAE30\uC758 **\uC704\uCE58\uB97C \uC77D\uB294\uB2E4** \u2014 \uC6B0\uB9AC\uB294 \uC870\uC791\uD558\uC9C0 \uC54A\uB294\uB2E4(\uBA85\uB839 \uC5C6\uC74C). IEC 61850 XCBR/XSWI Pos.",
1783
+ stateFields: ["position"],
1784
+ models: ["SwitchingState"],
1785
+ results: ["positionChanged"]
1786
+ },
1763
1787
  trackable: {
1764
1788
  key: "trackable",
1765
1789
  label: "twin.capability.trackable",
@@ -1808,7 +1832,7 @@ var CAPABILITIES = {
1808
1832
  results: ["stored", "discharged"]
1809
1833
  }
1810
1834
  };
1811
- var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "metered", "curtailable", "generating", "storing"];
1835
+ var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "switching", "metered", "curtailable", "generating", "storing"];
1812
1836
  function stateFieldsOf(caps) {
1813
1837
  const out = /* @__PURE__ */ new Set();
1814
1838
  for (const c of caps) for (const f of CAPABILITIES[c]?.stateFields ?? []) out.add(f);
@@ -5628,6 +5652,45 @@ var EmsKernel = class extends FlowEngine {
5628
5652
  }
5629
5653
  return max;
5630
5654
  }
5655
+ /**
5656
+ * **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
5657
+ *
5658
+ * 계층 계량(수전 ⊃ 분기)에서 전부 더하면 이중 계상이 된다. 뿌리만 더하면 현장의 실제 부하가 된다.
5659
+ * 모델이 그 계량기의 자리를 모르면 뿌리로 본다(모르는 것을 빼면 부하가 조용히 작아진다 — 그것이
5660
+ * 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
5661
+ */
5662
+ rootMeterIds() {
5663
+ const locOf = /* @__PURE__ */ new Map();
5664
+ for (const e of this.boardDef?.equipment ?? []) locOf.set(e.id, e.homeLocation);
5665
+ const parentOf = /* @__PURE__ */ new Map();
5666
+ for (const l of this.boardDef?.locations ?? []) parentOf.set(l.id, l.parentId);
5667
+ const meteredLocs = /* @__PURE__ */ new Set();
5668
+ for (const id of this.points.keys()) {
5669
+ const loc = locOf.get(id);
5670
+ if (loc) meteredLocs.add(loc);
5671
+ }
5672
+ const out = /* @__PURE__ */ new Set();
5673
+ for (const id of this.points.keys()) {
5674
+ const loc = locOf.get(id);
5675
+ if (!loc) {
5676
+ out.add(id);
5677
+ continue;
5678
+ }
5679
+ let cur = parentOf.get(loc);
5680
+ let root = true;
5681
+ const seen = /* @__PURE__ */ new Set([loc]);
5682
+ while (cur && !seen.has(cur)) {
5683
+ if (meteredLocs.has(cur)) {
5684
+ root = false;
5685
+ break;
5686
+ }
5687
+ seen.add(cur);
5688
+ cur = parentOf.get(cur);
5689
+ }
5690
+ if (root) out.add(id);
5691
+ }
5692
+ return out;
5693
+ }
5631
5694
  /**
5632
5695
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
5633
5696
  *
@@ -5661,7 +5724,8 @@ var EmsKernel = class extends FlowEngine {
5661
5724
  this.points.set(id, point);
5662
5725
  const w = this.open;
5663
5726
  if (kW !== void 0) {
5664
- const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
5727
+ const roots = this.rootMeterIds();
5728
+ const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
5665
5729
  if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
5666
5730
  w.samples++;
5667
5731
  w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {