@operato/twin-kernel 0.7.19 → 0.7.21

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`(이벤트에 등장해서 알게 된 자리). 소비처가 둘을 구별해야 한다: 관측으로 알게 된 자리는
@@ -1218,6 +1237,17 @@ export interface MeterPointState {
1218
1237
  export interface DemandWindowState {
1219
1238
  startMs: number;
1220
1239
  endMs: number;
1240
+ /**
1241
+ * 이 구간의 **최대가 찍힌 순간**의 지점별 kW — 요금이 걸린 수는 kW 다.
1242
+ *
1243
+ * 나중에 계산할 수 없다: 커널은 표본을 보관하지 않으므로 그 순간이 지나면 각 지점이 얼마였는지 알 길이
1244
+ * 없다. 그래서 최대가 갱신되는 순간에 찍어 둔다. 「어느 분기를 깎아야 피크가 내려가나」의 유일한 근거다
1245
+ * (전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
1246
+ */
1247
+ pointsAtPeak?: {
1248
+ id: string;
1249
+ kW: number;
1250
+ }[];
1221
1251
  /**
1222
1252
  * 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다).
1223
1253
  *
@@ -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();
@@ -292,8 +300,25 @@ export class EmsKernel extends FlowEngine {
292
300
  */
293
301
  const roots = this.rootMeterIds();
294
302
  const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
295
- if (w.maxKW === undefined || total > w.maxKW)
303
+ if (w.maxKW === undefined || total > w.maxKW) {
296
304
  w.maxKW = total;
305
+ /*
306
+ * ── 피크가 갱신된 **그 순간**의 지점별 kW 를 붙잡는다 (2026-08-18) ─────────
307
+ *
308
+ * 요금이 매겨지는 수는 kW(최대수요)이고 전력량(kWh)이 아니다. 그런데 지점별로 남기던 것은
309
+ * 구간 전력량뿐이라, 「어느 분기를 깎아야 피크가 내려가나」에 답할 수 없었다 — 전력량이 큰 분기가
310
+ * 피크를 만든 분기와 다를 수 있다(하루 종일 조금씩 쓰는 부하 vs 잠깐 크게 쓰는 부하).
311
+ *
312
+ * 나중에 계산할 수 없는 값이다: 표본을 보관하지 않으므로 그 순간이 지나면 각 지점이 얼마였는지
313
+ * 알 길이 없다. 그래서 **갱신되는 순간에** 찍는다.
314
+ *
315
+ * 뿌리만이 아니라 **모든 지점**을 찍는다: 뿌리 합이 피크를 만들지만, 그 피크를 누가 만들었는지는
316
+ * 하위 계량이 답한다. kW 를 모르는 지점은 넣지 않는다(0 으로 만들지 않는다).
317
+ */
318
+ w.pointsAtPeak = [...this.points.values()]
319
+ .filter(p => Number.isFinite(Number(p.kW)))
320
+ .map(p => ({ id: p.id, kW: Number(p.kW) }));
321
+ }
297
322
  w.samples++;
298
323
  w.meanKW = (w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples);
299
324
  }
@@ -379,6 +404,11 @@ export class EmsKernel extends FlowEngine {
379
404
  * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
380
405
  */
381
406
  ...(pointShares.length ? { points: pointShares } : {}),
407
+ /*
408
+ * 피크 순간의 지점별 kW — 요금이 걸린 수는 kW 다. 이것이 없으면 저널을 읽는 쪽은 「어느 분기를
409
+ * 깎아야 피크가 내려가나」에 답할 수 없다(전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
410
+ */
411
+ ...(w.pointsAtPeak?.length ? { pointsAtPeak: w.pointsAtPeak } : {}),
382
412
  /*
383
413
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
384
414
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -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);
@@ -6169,7 +6184,10 @@ var EmsKernel = class extends FlowEngine {
6169
6184
  if (kW !== void 0) {
6170
6185
  const roots = this.rootMeterIds();
6171
6186
  const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
6172
- if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
6187
+ if (w.maxKW === void 0 || total > w.maxKW) {
6188
+ w.maxKW = total;
6189
+ w.pointsAtPeak = [...this.points.values()].filter((p) => Number.isFinite(Number(p.kW))).map((p) => ({ id: p.id, kW: Number(p.kW) }));
6190
+ }
6173
6191
  w.samples++;
6174
6192
  w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
6175
6193
  }
@@ -6247,6 +6265,11 @@ var EmsKernel = class extends FlowEngine {
6247
6265
  * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
6248
6266
  */
6249
6267
  ...pointShares.length ? { points: pointShares } : {},
6268
+ /*
6269
+ * 피크 순간의 지점별 kW — 요금이 걸린 수는 kW 다. 이것이 없으면 저널을 읽는 쪽은 「어느 분기를
6270
+ * 깎아야 피크가 내려가나」에 답할 수 없다(전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
6271
+ */
6272
+ ...w.pointsAtPeak?.length ? { pointsAtPeak: w.pointsAtPeak } : {},
6250
6273
  /*
6251
6274
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
6252
6275
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -6942,6 +6965,7 @@ function retiredVocabularyIn(line) {
6942
6965
  documentPath,
6943
6966
  dueStatusOf,
6944
6967
  effectivityAt,
6968
+ electricalUpstreamOf,
6945
6969
  electricityCost,
6946
6970
  energyIntensity,
6947
6971
  energyOfWindows,
@@ -6957,6 +6981,7 @@ function retiredVocabularyIn(line) {
6957
6981
  ingest,
6958
6982
  ingestEnergyEquipmentRecords,
6959
6983
  ingestEnergyRecords,
6984
+ isElectricalLocationType,
6960
6985
  isEnergyEquipmentRecord,
6961
6986
  isEnergyRecord,
6962
6987
  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.21",
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": {