@operato/twin-kernel 0.7.19 → 0.7.20

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.
@@ -699,6 +699,25 @@ export interface LocationState {
699
699
  * 중간 단이 생기는 순간 그 아래 자리를 **집계에서 조용히 빠뜨린다**(정합성이 아니라 침묵이 문제다).
700
700
  */
701
701
  parentId?: string;
702
+ /**
703
+ * **전기적으로 어디서 받는가** — 이 자리에 전기를 주는 상류 자리(수전·분기).
704
+ *
705
+ * ── 왜 `parentId` 와 따로 두나 (2026-08-18) ─────────────────────────────────
706
+ * 한동안 분기의 `parentId` 에 수전을 적었다. 그러면 한 필드가 두 뜻을 겸한다: **공간 포함**(이 분기는
707
+ * 공장 안에 있다)과 **전기 상류**(이 분기는 수전에서 받는다). 겸용의 값은 조용히 새어 나간다 — 공간
708
+ * 해석기가 수전을 「구역」으로 읽어 매 인제스트마다 거짓 경보를 냈고(「area 가 없는 것을 가리킨다」),
709
+ * 계통을 읽는 쪽은 공간 부모를 전기 상류로 오해할 위험을 안고 있었다.
710
+ *
711
+ * 두 관계는 실제로 다르다: 분기는 공장 **안에** 있고(공간), 수전에서 **받는다**(전기). 한 자리가 둘을
712
+ * 함께 가질 수 있으므로 필드도 둘이어야 한다.
713
+ *
714
+ * **옛 세대는 읽는 경계에서 흡수한다**(`electricalUpstreamOf`): 이 값이 없고 `parentId` 가 전기 자리를
715
+ * 가리키면 그것을 상류로 읽는다. 저장된 모델을 고치지 않고도 두 세대가 같은 답을 낸다.
716
+ *
717
+ * ⚠️ **물류 흐름의 상·하류가 아니다.** 흐름 쪽에는 같은 낱말이 다른 뜻으로 있다(씬의 `downstreamRef` 는
718
+ * 물건이 다음에 갈 곳이다). 이 값은 **전기를 어디서 받는가**이고, 물건의 이동과 무관하다.
719
+ */
720
+ upstreamId?: string;
702
721
  /**
703
722
  * 이 자리를 **어떻게 알게 됐는가** — `master`(원 시스템 마스터/저작이 말해 준 자리) ·
704
723
  * `observed`(이벤트에 등장해서 알게 된 자리). 소비처가 둘을 구별해야 한다: 관측으로 알게 된 자리는
@@ -28,7 +28,7 @@
28
28
  import { FlowEngine } from "./flow-engine.js";
29
29
  import { firstFitPolicy } from "./allocation-policy.js";
30
30
  import { ENERGY_EVENT } from "./contract.js";
31
- import { EMS_PROPERTY } from "./ems-profile.js";
31
+ import { EMS_PROPERTY, electricalUpstreamOf } from "./ems-profile.js";
32
32
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
33
33
  export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
34
34
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
@@ -128,9 +128,17 @@ export class EmsKernel extends FlowEngine {
128
128
  const locOf = new Map();
129
129
  for (const e of this.boardDef?.equipment ?? [])
130
130
  locOf.set(e.id, e.homeLocation);
131
+ /*
132
+ * 계층을 **전기 상류**로 걷는다 — 공간 부모가 아니다.
133
+ *
134
+ * 예전에는 `parentId` 를 그대로 걸었다. 그때는 분기의 `parentId` 에 수전이 적혀 있어서 답이 같았지만,
135
+ * 공간 부모(공장·구역)를 적은 모델에서는 그 사슬이 **전기적으로 뜻이 없는 길**을 따라간다. 상류의
136
+ * 뜻을 읽는 규칙은 한 곳에 있다(`electricalUpstreamOf` — 옛 세대도 그 안에서 흡수한다).
137
+ */
138
+ const locs = this.boardDef?.locations ?? [];
131
139
  const parentOf = new Map();
132
- for (const l of this.boardDef?.locations ?? [])
133
- parentOf.set(l.id, l.parentId);
140
+ for (const l of locs)
141
+ parentOf.set(l.id, electricalUpstreamOf(locs, l.id));
134
142
  /* 계량되고 있는 자리들 — 표본이 실제로 온 계량기의 자리만 센다(선언만 있고 값이 없는 계량기는
135
143
  부하에 기여하지 않으므로 계층 판정에서도 제외한다). */
136
144
  const meteredLocs = new Set();
@@ -67,3 +67,22 @@ export interface EmsPropertySpec {
67
67
  }
68
68
  export declare const EMS_PROPERTY_SPEC: Record<string, EmsPropertySpec>;
69
69
  export declare const EMS_TYPES: TwinTypeInfo[];
70
+ /**
71
+ * 이 자리의 **전기 상류** — 어디서 전기를 받는가.
72
+ *
73
+ * ── 왜 함수로 두나 (2026-08-18) ─────────────────────────────────────────────
74
+ * 두 세대가 섞여 있다. 새 모델은 `upstream` 을 적고, 그 전 모델은 분기의 `parentId` 에 수전을 적었다.
75
+ * 판정을 소비처마다 쓰면(호스트·계통도·커널) 한쪽만 고쳐지고 그때부터 화면과 계산이 다른 계통을 말한다.
76
+ * 그래서 **읽는 규칙을 한 곳**에 둔다.
77
+ *
78
+ * 옛 세대를 흡수하는 조건이 좁다: `parentId` 가 **전기 자리**(수전·분기·구역 계량)를 가리킬 때만
79
+ * 상류로 읽는다. 공장·구역 같은 공간 부모를 상류로 읽으면 없는 결선을 만들어 낸다.
80
+ */
81
+ export declare function electricalUpstreamOf(locations: readonly {
82
+ id: string;
83
+ type?: string;
84
+ parentId?: string;
85
+ upstreamId?: string;
86
+ }[] | undefined | null, id: string): string | undefined;
87
+ /** 전기 계통의 자리인가 — 수전·분기·구역 계량. */
88
+ export declare function isElectricalLocationType(type: string): boolean;
@@ -210,3 +210,30 @@ export const EMS_TYPES = [
210
210
  capabilities: ['curtailable', 'metered', 'operable']
211
211
  }
212
212
  ];
213
+ /**
214
+ * 이 자리의 **전기 상류** — 어디서 전기를 받는가.
215
+ *
216
+ * ── 왜 함수로 두나 (2026-08-18) ─────────────────────────────────────────────
217
+ * 두 세대가 섞여 있다. 새 모델은 `upstream` 을 적고, 그 전 모델은 분기의 `parentId` 에 수전을 적었다.
218
+ * 판정을 소비처마다 쓰면(호스트·계통도·커널) 한쪽만 고쳐지고 그때부터 화면과 계산이 다른 계통을 말한다.
219
+ * 그래서 **읽는 규칙을 한 곳**에 둔다.
220
+ *
221
+ * 옛 세대를 흡수하는 조건이 좁다: `parentId` 가 **전기 자리**(수전·분기·구역 계량)를 가리킬 때만
222
+ * 상류로 읽는다. 공장·구역 같은 공간 부모를 상류로 읽으면 없는 결선을 만들어 낸다.
223
+ */
224
+ export function electricalUpstreamOf(locations, id) {
225
+ const byId = new Map((locations ?? []).filter(l => l?.id).map(l => [String(l.id), l]));
226
+ const self = byId.get(String(id));
227
+ if (!self)
228
+ return undefined;
229
+ if (self.upstreamId)
230
+ return String(self.upstreamId);
231
+ const parent = self.parentId ? byId.get(String(self.parentId)) : undefined;
232
+ if (!parent)
233
+ return undefined;
234
+ return isElectricalLocationType(String(parent.type ?? '')) ? String(parent.id) : undefined;
235
+ }
236
+ /** 전기 계통의 자리인가 — 수전·분기·구역 계량. */
237
+ export function isElectricalLocationType(type) {
238
+ return EMS_LOCATION_TYPES.includes(type);
239
+ }
@@ -97,6 +97,7 @@ __export(index_exports, {
97
97
  documentPath: () => documentPath,
98
98
  dueStatusOf: () => dueStatusOf,
99
99
  effectivityAt: () => effectivityAt,
100
+ electricalUpstreamOf: () => electricalUpstreamOf,
100
101
  electricityCost: () => electricityCost,
101
102
  energyIntensity: () => energyIntensity,
102
103
  energyOfWindows: () => energyOfWindows,
@@ -112,6 +113,7 @@ __export(index_exports, {
112
113
  ingest: () => ingest,
113
114
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
114
115
  ingestEnergyRecords: () => ingestEnergyRecords,
116
+ isElectricalLocationType: () => isElectricalLocationType,
115
117
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
116
118
  isEnergyRecord: () => isEnergyRecord,
117
119
  isEquipmentLevel: () => isEquipmentLevel,
@@ -1894,6 +1896,18 @@ var EMS_TYPES = [
1894
1896
  capabilities: ["curtailable", "metered", "operable"]
1895
1897
  }
1896
1898
  ];
1899
+ function electricalUpstreamOf(locations, id) {
1900
+ const byId = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
1901
+ const self = byId.get(String(id));
1902
+ if (!self) return void 0;
1903
+ if (self.upstreamId) return String(self.upstreamId);
1904
+ const parent = self.parentId ? byId.get(String(self.parentId)) : void 0;
1905
+ if (!parent) return void 0;
1906
+ return isElectricalLocationType(String(parent.type ?? "")) ? String(parent.id) : void 0;
1907
+ }
1908
+ function isElectricalLocationType(type) {
1909
+ return EMS_LOCATION_TYPES.includes(type);
1910
+ }
1897
1911
 
1898
1912
  // src/capability.ts
1899
1913
  var CAPABILITIES = {
@@ -6049,8 +6063,9 @@ var EmsKernel = class extends FlowEngine {
6049
6063
  rootMeterIds() {
6050
6064
  const locOf = /* @__PURE__ */ new Map();
6051
6065
  for (const e of this.boardDef?.equipment ?? []) locOf.set(e.id, e.homeLocation);
6066
+ const locs = this.boardDef?.locations ?? [];
6052
6067
  const parentOf = /* @__PURE__ */ new Map();
6053
- for (const l of this.boardDef?.locations ?? []) parentOf.set(l.id, l.parentId);
6068
+ for (const l of locs) parentOf.set(l.id, electricalUpstreamOf(locs, l.id));
6054
6069
  const meteredLocs = /* @__PURE__ */ new Set();
6055
6070
  for (const id of this.points.keys()) {
6056
6071
  const loc = locOf.get(id);
@@ -6942,6 +6957,7 @@ function retiredVocabularyIn(line) {
6942
6957
  documentPath,
6943
6958
  dueStatusOf,
6944
6959
  effectivityAt,
6960
+ electricalUpstreamOf,
6945
6961
  electricityCost,
6946
6962
  energyIntensity,
6947
6963
  energyOfWindows,
@@ -6957,6 +6973,7 @@ function retiredVocabularyIn(line) {
6957
6973
  ingest,
6958
6974
  ingestEnergyEquipmentRecords,
6959
6975
  ingestEnergyRecords,
6976
+ isElectricalLocationType,
6960
6977
  isEnergyEquipmentRecord,
6961
6978
  isEnergyRecord,
6962
6979
  isEquipmentLevel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
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": {