@operato/twin-kernel 0.7.18 → 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`(이벤트에 등장해서 알게 된 자리). 소비처가 둘을 구별해야 한다: 관측으로 알게 된 자리는
@@ -1206,6 +1225,14 @@ export interface MeterPointState {
1206
1225
  atMs?: number;
1207
1226
  /** 이 구간에 받은 표본 수 — 「못 쟀다」와 「0이었다」를 구별한다. */
1208
1227
  samplesInWindow: number;
1228
+ /**
1229
+ * 이 구간이 열릴 때의 적산값 — **구간 전력량을 뺄셈으로 얻기 위한 기준점.**
1230
+ *
1231
+ * 지점별 구간 전력량은 「끝 적산 − 시작 적산」이다. 그것을 우리가 kW 로 적분하지 않는 이유는,
1232
+ * 적산은 **계량기 자신의 회계**이고 그것이 요금의 근거이기 때문이다(우리가 적분한 값과 미세하게
1233
+ * 다르며, 다를 때 맞는 쪽은 계량기다).
1234
+ */
1235
+ kWhAtWindowStart?: number;
1209
1236
  }
1210
1237
  export interface DemandWindowState {
1211
1238
  startMs: number;
@@ -69,6 +69,17 @@ export declare class EmsKernel extends FlowEngine {
69
69
  * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
70
70
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
71
71
  */
72
+ /**
73
+ * 계측·설비 상태는 **에너지 어휘로** 받는다(물품 투영으로 보내지 않는다).
74
+ *
75
+ * ── 관측 시각을 함께 민다 (2026-08-18) ──────────────────────────────────────
76
+ * 이 두 문은 투영기(`ObservedReducer`)를 거치지 않는다. 그래서 「마지막으로 들은 시각」이 갱신되지
77
+ * 않았고, 초당 여러 건의 계측을 받는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼다**: 커널의
78
+ * "지금" 이 시뮬 기준시각(2026-01-01)에 얼어, 구간 마감·피크·감축 제안이 전부 8개월 전으로 기록됐다.
79
+ *
80
+ * 계측 자체는 원천 시각을 달고 있어서 화면의 순간값은 정상으로 보였다 — 그래서 조용한 결함이었다.
81
+ * 저널을 기간으로 접는 쪽(성과·요금·피더 배분)은 그 트윈에서 영원히 아무것도 찾지 못했다.
82
+ */
72
83
  apply(envelope: CanonicalEnvelope): void;
73
84
  /**
74
85
  * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
@@ -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();
@@ -168,6 +176,17 @@ export class EmsKernel extends FlowEngine {
168
176
  * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
169
177
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
170
178
  */
179
+ /**
180
+ * 계측·설비 상태는 **에너지 어휘로** 받는다(물품 투영으로 보내지 않는다).
181
+ *
182
+ * ── 관측 시각을 함께 민다 (2026-08-18) ──────────────────────────────────────
183
+ * 이 두 문은 투영기(`ObservedReducer`)를 거치지 않는다. 그래서 「마지막으로 들은 시각」이 갱신되지
184
+ * 않았고, 초당 여러 건의 계측을 받는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼다**: 커널의
185
+ * "지금" 이 시뮬 기준시각(2026-01-01)에 얼어, 구간 마감·피크·감축 제안이 전부 8개월 전으로 기록됐다.
186
+ *
187
+ * 계측 자체는 원천 시각을 달고 있어서 화면의 순간값은 정상으로 보였다 — 그래서 조용한 결함이었다.
188
+ * 저널을 기간으로 접는 쪽(성과·요금·피더 배분)은 그 트윈에서 영원히 아무것도 찾지 못했다.
189
+ */
171
190
  apply(envelope) {
172
191
  if (envelope.eventType === ENERGY_EVENT.measured) {
173
192
  this.ingestMeasured(envelope);
@@ -215,6 +234,10 @@ export class EmsKernel extends FlowEngine {
215
234
  /* **값과 함께 시각을 남긴다** — 없으면 화면이 멈춘 값을 지금 값으로 그린다(계량 지점과 같은 규율). */
216
235
  if (d?.at)
217
236
  eq.measuredAt = d.at;
237
+ /* 설비 상태도 관측이다 — 계측과 같은 규칙으로 「마지막으로 들은 시각」을 민다(규칙 한 벌). */
238
+ const heardAt = Date.parse(String(d?.at ?? envelope.eventTime ?? ''));
239
+ if (Number.isFinite(heardAt))
240
+ this.noteObserved(heardAt);
218
241
  this.revision++;
219
242
  }
220
243
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
@@ -231,6 +254,8 @@ export class EmsKernel extends FlowEngine {
231
254
  const id = String(d?.meterId ?? '').trim();
232
255
  if (!id)
233
256
  throw new Error('energy.measured has no meterId — a measurement with no meter cannot be accumulated');
257
+ /* 들은 시각을 먼저 민다 — 이 뒤에 나오는 사실(구간 마감·피크)의 시각이 여기서 나온다. */
258
+ this.noteObserved(atMs);
234
259
  /* 이 표본이 새 구간의 것이면 앞 구간을 먼저 닫는다 — 마감이 표본보다 늦으면 최대가 섞인다. */
235
260
  this.closeDue(atMs);
236
261
  this.openWindow(atMs);
@@ -246,6 +271,15 @@ export class EmsKernel extends FlowEngine {
246
271
  if (Number.isFinite(Number(d?.powerFactor)))
247
272
  point.powerFactor = Number(d.powerFactor);
248
273
  }
274
+ /*
275
+ * 기준점이 아직 없으면 **이 구간의 첫 적산**으로 심는다.
276
+ *
277
+ * 구간이 열릴 때 심는 것이 원칙이다(그러면 구간 경계에서 전기가 새지 않는다 — 앞 구간의 마지막
278
+ * 적산이 이어진다). 그런데 처음 보는 지점은 그때 아직 존재하지 않으므로 심을 값이 없다. 그 지점의
279
+ * **첫 구간만** 첫 표본을 기준으로 삼는다 — 그 앞의 소비는 우리가 듣지 못한 것이고, 지어내지 않는다.
280
+ */
281
+ if (point.kWhAtWindowStart === undefined && point.kWh !== undefined)
282
+ point.kWhAtWindowStart = point.kWh;
249
283
  point.samplesInWindow++;
250
284
  this.points.set(id, point);
251
285
  const w = this.open;
@@ -280,9 +314,12 @@ export class EmsKernel extends FlowEngine {
280
314
  return;
281
315
  const contractKW = this.declaredContractKW();
282
316
  this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...(contractKW !== undefined ? { contractKW } : {}) };
283
- /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다. */
284
- for (const p of this.points.values())
317
+ /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다.
318
+ 적산 기준점도 함께 찍는다 — 이 구간의 지점별 전력량은 「끝 − 시작」으로 나온다. */
319
+ for (const p of this.points.values()) {
285
320
  p.samplesInWindow = 0;
321
+ p.kWhAtWindowStart = p.kWh;
322
+ }
286
323
  this.suggestedFor = undefined;
287
324
  }
288
325
  /**
@@ -300,6 +337,16 @@ export class EmsKernel extends FlowEngine {
300
337
  const w = this.open;
301
338
  if (w.contractKW !== undefined && w.maxKW !== undefined)
302
339
  w.overContract = w.maxKW > w.contractKW;
340
+ /* 지점별 구간 전력량 — 적산의 뺄셈. 기준점이 없거나 적산이 줄었으면(계기 교체) 내지 않는다. */
341
+ const roots = this.rootMeterIds();
342
+ const pointShares = [...this.points.values()]
343
+ .map(p => {
344
+ const from = p.kWhAtWindowStart;
345
+ const to = p.kWh;
346
+ const kWh = from !== undefined && to !== undefined && to >= from ? to - from : undefined;
347
+ return { id: p.id, ...(kWh !== undefined ? { kWh } : {}), ...(roots.has(p.id) ? { root: true } : {}) };
348
+ })
349
+ .filter(p => p.kWh !== undefined);
303
350
  this.closed.push(w);
304
351
  this.closedTotal++;
305
352
  if (this.closed.length > KEEP_CLOSED)
@@ -324,6 +371,22 @@ export class EmsKernel extends FlowEngine {
324
371
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
325
372
  */
326
373
  ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {}),
374
+ /*
375
+ * ── 지점별 몫을 함께 낸다 (2026-08-18) ────────────────────────────────
376
+ *
377
+ * 분기마다 계량기가 있는 현장에서 분기별 전력량은 **측정**이다. 그런데 그 사실이 저널에
378
+ * 없어서, 저널을 접는 쪽은 가동시간으로 **배분**할 수밖에 없었다 — 계량이 있는데 배분으로
379
+ * 말하는 것은 근거를 실제보다 약하게 보고하는 것이고, 그 위에 세운 SEU 판단이 무거운 값을 놓친다.
380
+ *
381
+ * 표본을 그대로 저널에 흘리는 대신 **구간마다 한 줄**로 낸다: 계측은 초당 여러 건이라 표본을
382
+ * 저널에서 읽게 하면 조회 상한에 먼저 걸린다(호스트가 실제로 그 벽에 부딪혔다).
383
+ *
384
+ * 값은 적산의 뺄셈이다 — 계량기 자신의 회계이고, 그것이 요금의 근거다. 적산을 주지 않는
385
+ * 지점은 **빠진다**(우리가 적분한 수를 계량기가 잰 것처럼 내지 않는다).
386
+ *
387
+ * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
388
+ */
389
+ ...(pointShares.length ? { points: pointShares } : {}),
327
390
  /*
328
391
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
329
392
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -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
+ }
@@ -32,6 +32,18 @@ export interface EnergyPool {
32
32
  * 같은 규율이고, 그 결과는 배분(`apportioned`)이며 `overhead: true` 로 표시된다.
33
33
  */
34
34
  overhead?: boolean;
35
+ /**
36
+ * **하위 계량이 덮지 않는 나머지** — 뿌리 계량에서 하위 계량의 합을 뺀 몫.
37
+ *
38
+ * ── 왜 종류를 따로 두나 (2026-08-18) ──────────────────────────────────────
39
+ * 분기마다 계량기가 있는 현장에서 분기별 값은 **측정**이다(배분이 아니다). 그런데 분기 계량의 합은
40
+ * 뿌리 계량보다 대개 작다 — 수전에 바로 매달린 부하, 변압기 손실, 계량되지 않은 회로가 그 차이다.
41
+ * 그 차이를 어느 분기에 얹으면 그 분기가 쓰지 않은 전기를 쓴 것으로 만들고, 버리면 합이 깨진다.
42
+ *
43
+ * 그래서 나머지도 **하나의 몫**으로 남기되, 「덮는 대상이 선언되지 않았다(no-consumers)」와는 다른
44
+ * 사실로 적는다. 앞의 것은 사람이 선언을 채워야 하는 결손이고, 이것은 **계량 범위의 사실**이다.
45
+ */
46
+ remainder?: boolean;
35
47
  }
36
48
  export interface EnergyShare {
37
49
  consumerId: string;
@@ -54,10 +66,11 @@ export interface Unattributed {
54
66
  kWh: number;
55
67
  /**
56
68
  * 왜 나누지 못했나 — **언어중립 코드**(화면이 옮긴다).
57
- * · `no-consumers` — 이 계량기가 무엇을 덮는지 선언되지 않았다
58
- * · `no-weights` — 소비처는 있는데 나눌 몫이 없다(가동시간·산출량 어느 것도)
69
+ * · `no-consumers` — 이 계량기가 무엇을 덮는지 선언되지 않았다
70
+ * · `no-weights` — 소비처는 있는데 나눌 몫이 없다(가동시간·산출량 어느 것도)
71
+ * · `not-submetered` — 뿌리에서 재었지만 **하위 계량이 덮지 않는** 부분이다(§EnergyPool.remainder)
59
72
  */
60
- reason: 'no-consumers' | 'no-weights';
73
+ reason: 'no-consumers' | 'no-weights' | 'not-submetered';
61
74
  /** 몫을 못 정한 소비처들 — 사람이 무엇을 채워야 할지 알 수 있게. */
62
75
  consumerIds?: string[];
63
76
  }
@@ -84,10 +84,11 @@ export function attributeEnergy(opts) {
84
84
  });
85
85
  continue;
86
86
  }
87
- /* ② 덮는 소비처가 선언되지 않았다 — 나눌 대상이 없다. */
87
+ /* ② 덮는 소비처가 선언되지 않았다 — 나눌 대상이 없다.
88
+ 나머지 몫(`remainder`)은 **결손이 아니라 계량 범위의 사실**이므로 다른 코드로 적는다. */
88
89
  const covered = (pool.consumerIds ?? []).map(id => byId.get(id)).filter((c) => !!c);
89
90
  if (!covered.length) {
90
- unattributed.push({ poolMeterId: pool.meterId, kWh, reason: 'no-consumers' });
91
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? 'not-submetered' : 'no-consumers' });
91
92
  continue;
92
93
  }
93
94
  /* ③ 하나뿐이면 그것도 측정이다 — 그 계량기가 그 소비처만 덮는다는 선언이므로. */
@@ -502,6 +502,10 @@ export declare abstract class FlowEngine implements TwinKernel {
502
502
  *
503
503
  * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
504
504
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
505
+ *
506
+ * 들은 시각의 출처가 **둘**이다: 물류 투영기(`ObservedReducer`)와, 도메인 어휘로 직접 받는 경로
507
+ * (`noteObserved` — 에너지 계측). 둘 중 **나중 것**을 쓴다: 한쪽만 보면 그 트윈이 실제로 듣고 있는데도
508
+ * 「아무것도 못 들었다」가 되어, 커널이 내는 사실이 시뮬 기준시각(2026-01-01)으로 기록된다.
505
509
  */
506
510
  protected nowMs(): number;
507
511
  /**
@@ -567,6 +571,18 @@ export declare abstract class FlowEngine implements TwinKernel {
567
571
  private settleObserved;
568
572
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
569
573
  private observedHandlers;
574
+ /**
575
+ * 관측 시각을 앞으로 민다 — **투영기를 거치지 않는 관측을 위한 문.**
576
+ *
577
+ * 도메인 커널이 자기 어휘의 관측을 `apply()` 에서 먼저 처리하고 돌아가는 경우가 있다(에너지 계측이
578
+ * 그렇다 — 물품 투영과 어휘가 다르므로 옳다). 그때도 「마지막으로 들은 시각」은 갱신돼야 한다:
579
+ * 그것이 관측 구동 커널의 **지금**이고, 커널이 내는 모든 사실의 시각이 여기서 나온다.
580
+ *
581
+ * 이 문이 없던 동안 미러 에너지 트윈의 사실들이 시뮬 기준시각으로 기록됐다([[noteObservedAt]]).
582
+ */
583
+ protected noteObserved(atMs: number): void;
584
+ /** 도메인 어휘로 직접 들은 마지막 관측 시각 — 투영기를 거치지 않는 경로의 「지금」. */
585
+ private heardAtMs?;
570
586
  /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
571
587
  get observing(): boolean;
572
588
  /**
@@ -1150,10 +1150,17 @@ export class FlowEngine {
1150
1150
  *
1151
1151
  * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
1152
1152
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
1153
+ *
1154
+ * 들은 시각의 출처가 **둘**이다: 물류 투영기(`ObservedReducer`)와, 도메인 어휘로 직접 받는 경로
1155
+ * (`noteObserved` — 에너지 계측). 둘 중 **나중 것**을 쓴다: 한쪽만 보면 그 트윈이 실제로 듣고 있는데도
1156
+ * 「아무것도 못 들었다」가 되어, 커널이 내는 사실이 시뮬 기준시각(2026-01-01)으로 기록된다.
1153
1157
  */
1154
1158
  nowMs() {
1155
- const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
1156
- return observed ?? this.originMs + this.clockMs;
1159
+ if (!this.observeMode)
1160
+ return this.originMs + this.clockMs;
1161
+ const fromProjection = this.observer?.lastObservedMs;
1162
+ const heard = [fromProjection, this.heardAtMs].filter((v) => Number.isFinite(v));
1163
+ return heard.length ? Math.max(...heard) : this.originMs + this.clockMs;
1157
1164
  }
1158
1165
  /**
1159
1166
  * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
@@ -1253,6 +1260,28 @@ export class FlowEngine {
1253
1260
  observedHandlers() {
1254
1261
  return this.handlersRef();
1255
1262
  }
1263
+ /**
1264
+ * 관측 시각을 앞으로 민다 — **투영기를 거치지 않는 관측을 위한 문.**
1265
+ *
1266
+ * 도메인 커널이 자기 어휘의 관측을 `apply()` 에서 먼저 처리하고 돌아가는 경우가 있다(에너지 계측이
1267
+ * 그렇다 — 물품 투영과 어휘가 다르므로 옳다). 그때도 「마지막으로 들은 시각」은 갱신돼야 한다:
1268
+ * 그것이 관측 구동 커널의 **지금**이고, 커널이 내는 모든 사실의 시각이 여기서 나온다.
1269
+ *
1270
+ * 이 문이 없던 동안 미러 에너지 트윈의 사실들이 시뮬 기준시각으로 기록됐다([[noteObservedAt]]).
1271
+ */
1272
+ noteObserved(atMs) {
1273
+ if (!Number.isFinite(atMs))
1274
+ return;
1275
+ /*
1276
+ * 투영기에도 알리지만(있으면), **커널 자신도 기억한다.** 투영기는 첫 물류 이벤트에서야 생기므로,
1277
+ * 계측만 받는 트윈(에너지 미러)에는 아직 없을 수 있다 — 그 경우 알릴 곳이 없어 조용히 사라졌다.
1278
+ */
1279
+ this.observer?.noteObservedAt(atMs);
1280
+ if (this.heardAtMs === undefined || atMs > this.heardAtMs)
1281
+ this.heardAtMs = atMs;
1282
+ }
1283
+ /** 도메인 어휘로 직접 들은 마지막 관측 시각 — 투영기를 거치지 않는 경로의 「지금」. */
1284
+ heardAtMs;
1256
1285
  /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
1257
1286
  get observing() {
1258
1287
  return this.observeMode;
@@ -181,6 +181,22 @@ export declare class ObservedReducer {
181
181
  * `undefined` — 없는 기준으로 단정하지 않는다.
182
182
  */
183
183
  get lastObservedMs(): number | undefined;
184
+ /**
185
+ * 관측 시각을 앞으로 민다 — **이 문을 거치지 않는 관측이 있다.**
186
+ *
187
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
188
+ * 에너지 커널은 계측(`energy.measured`)을 이 리듀서 **앞에서** 가로채 자기 구간에 누적한다(물류 투영과
189
+ * 어휘가 다르므로 옳다). 그런데 그 경로가 「마지막으로 들은 시각」을 갱신하지 않았다. 그래서 계측이
190
+ * 초당 여러 건 들어오는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼고**, 커널의 "지금" 이
191
+ * 시뮬 기준시각(BASE_EPOCH, 2026-01-01)에 얼어 있었다.
192
+ *
193
+ * 결과는 조용했다: 구간 마감·피크 같은 **커널이 낸 사실이 8개월 전으로 기록**되어, 저널을 기간으로
194
+ * 접는 소비처(성과·요금·피더 배분)가 그 트윈에서 영원히 아무것도 찾지 못했다. 계측 자체는 원천 시각을
195
+ * 달고 있었으므로 화면의 순간값만 정상으로 보였다 — 그래서 눈에 띄지 않았다.
196
+ *
197
+ * 시각을 되돌리지는 않는다: 늦게 도착한 옛 표본이 "지금" 을 과거로 끌면 그 뒤 판정이 전부 흔들린다.
198
+ */
199
+ noteObservedAt(atMs: number): void;
184
200
  /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
185
201
  private rememberShift;
186
202
  /**
@@ -565,6 +565,27 @@ export class ObservedReducer {
565
565
  get lastObservedMs() {
566
566
  return this.observedAtMs;
567
567
  }
568
+ /**
569
+ * 관측 시각을 앞으로 민다 — **이 문을 거치지 않는 관측이 있다.**
570
+ *
571
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
572
+ * 에너지 커널은 계측(`energy.measured`)을 이 리듀서 **앞에서** 가로채 자기 구간에 누적한다(물류 투영과
573
+ * 어휘가 다르므로 옳다). 그런데 그 경로가 「마지막으로 들은 시각」을 갱신하지 않았다. 그래서 계측이
574
+ * 초당 여러 건 들어오는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼고**, 커널의 "지금" 이
575
+ * 시뮬 기준시각(BASE_EPOCH, 2026-01-01)에 얼어 있었다.
576
+ *
577
+ * 결과는 조용했다: 구간 마감·피크 같은 **커널이 낸 사실이 8개월 전으로 기록**되어, 저널을 기간으로
578
+ * 접는 소비처(성과·요금·피더 배분)가 그 트윈에서 영원히 아무것도 찾지 못했다. 계측 자체는 원천 시각을
579
+ * 달고 있었으므로 화면의 순간값만 정상으로 보였다 — 그래서 눈에 띄지 않았다.
580
+ *
581
+ * 시각을 되돌리지는 않는다: 늦게 도착한 옛 표본이 "지금" 을 과거로 끌면 그 뒤 판정이 전부 흔들린다.
582
+ */
583
+ noteObservedAt(atMs) {
584
+ if (!Number.isFinite(atMs))
585
+ return;
586
+ if (this.observedAtMs === undefined || atMs > this.observedAtMs)
587
+ this.observedAtMs = atMs;
588
+ }
568
589
  /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
569
590
  rememberShift(key, r) {
570
591
  if (!r.workCalendar?.length && !r.window)
@@ -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,
@@ -1515,6 +1517,25 @@ var ObservedReducer = class {
1515
1517
  get lastObservedMs() {
1516
1518
  return this.observedAtMs;
1517
1519
  }
1520
+ /**
1521
+ * 관측 시각을 앞으로 민다 — **이 문을 거치지 않는 관측이 있다.**
1522
+ *
1523
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
1524
+ * 에너지 커널은 계측(`energy.measured`)을 이 리듀서 **앞에서** 가로채 자기 구간에 누적한다(물류 투영과
1525
+ * 어휘가 다르므로 옳다). 그런데 그 경로가 「마지막으로 들은 시각」을 갱신하지 않았다. 그래서 계측이
1526
+ * 초당 여러 건 들어오는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼고**, 커널의 "지금" 이
1527
+ * 시뮬 기준시각(BASE_EPOCH, 2026-01-01)에 얼어 있었다.
1528
+ *
1529
+ * 결과는 조용했다: 구간 마감·피크 같은 **커널이 낸 사실이 8개월 전으로 기록**되어, 저널을 기간으로
1530
+ * 접는 소비처(성과·요금·피더 배분)가 그 트윈에서 영원히 아무것도 찾지 못했다. 계측 자체는 원천 시각을
1531
+ * 달고 있었으므로 화면의 순간값만 정상으로 보였다 — 그래서 눈에 띄지 않았다.
1532
+ *
1533
+ * 시각을 되돌리지는 않는다: 늦게 도착한 옛 표본이 "지금" 을 과거로 끌면 그 뒤 판정이 전부 흔들린다.
1534
+ */
1535
+ noteObservedAt(atMs) {
1536
+ if (!Number.isFinite(atMs)) return;
1537
+ if (this.observedAtMs === void 0 || atMs > this.observedAtMs) this.observedAtMs = atMs;
1538
+ }
1518
1539
  /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
1519
1540
  rememberShift(key, r) {
1520
1541
  if (!r.workCalendar?.length && !r.window) return;
@@ -1875,6 +1896,18 @@ var EMS_TYPES = [
1875
1896
  capabilities: ["curtailable", "metered", "operable"]
1876
1897
  }
1877
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
+ }
1878
1911
 
1879
1912
  // src/capability.ts
1880
1913
  var CAPABILITIES = {
@@ -3799,10 +3832,16 @@ var FlowEngine = class {
3799
3832
  *
3800
3833
  * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
3801
3834
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
3835
+ *
3836
+ * 들은 시각의 출처가 **둘**이다: 물류 투영기(`ObservedReducer`)와, 도메인 어휘로 직접 받는 경로
3837
+ * (`noteObserved` — 에너지 계측). 둘 중 **나중 것**을 쓴다: 한쪽만 보면 그 트윈이 실제로 듣고 있는데도
3838
+ * 「아무것도 못 들었다」가 되어, 커널이 내는 사실이 시뮬 기준시각(2026-01-01)으로 기록된다.
3802
3839
  */
3803
3840
  nowMs() {
3804
- const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
3805
- return observed ?? this.originMs + this.clockMs;
3841
+ if (!this.observeMode) return this.originMs + this.clockMs;
3842
+ const fromProjection = this.observer?.lastObservedMs;
3843
+ const heard = [fromProjection, this.heardAtMs].filter((v) => Number.isFinite(v));
3844
+ return heard.length ? Math.max(...heard) : this.originMs + this.clockMs;
3806
3845
  }
3807
3846
  /**
3808
3847
  * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
@@ -3893,6 +3932,22 @@ var FlowEngine = class {
3893
3932
  observedHandlers() {
3894
3933
  return this.handlersRef();
3895
3934
  }
3935
+ /**
3936
+ * 관측 시각을 앞으로 민다 — **투영기를 거치지 않는 관측을 위한 문.**
3937
+ *
3938
+ * 도메인 커널이 자기 어휘의 관측을 `apply()` 에서 먼저 처리하고 돌아가는 경우가 있다(에너지 계측이
3939
+ * 그렇다 — 물품 투영과 어휘가 다르므로 옳다). 그때도 「마지막으로 들은 시각」은 갱신돼야 한다:
3940
+ * 그것이 관측 구동 커널의 **지금**이고, 커널이 내는 모든 사실의 시각이 여기서 나온다.
3941
+ *
3942
+ * 이 문이 없던 동안 미러 에너지 트윈의 사실들이 시뮬 기준시각으로 기록됐다([[noteObservedAt]]).
3943
+ */
3944
+ noteObserved(atMs) {
3945
+ if (!Number.isFinite(atMs)) return;
3946
+ this.observer?.noteObservedAt(atMs);
3947
+ if (this.heardAtMs === void 0 || atMs > this.heardAtMs) this.heardAtMs = atMs;
3948
+ }
3949
+ /** 도메인 어휘로 직접 들은 마지막 관측 시각 — 투영기를 거치지 않는 경로의 「지금」. */
3950
+ heardAtMs;
3896
3951
  /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
3897
3952
  get observing() {
3898
3953
  return this.observeMode;
@@ -6008,8 +6063,9 @@ var EmsKernel = class extends FlowEngine {
6008
6063
  rootMeterIds() {
6009
6064
  const locOf = /* @__PURE__ */ new Map();
6010
6065
  for (const e of this.boardDef?.equipment ?? []) locOf.set(e.id, e.homeLocation);
6066
+ const locs = this.boardDef?.locations ?? [];
6011
6067
  const parentOf = /* @__PURE__ */ new Map();
6012
- 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));
6013
6069
  const meteredLocs = /* @__PURE__ */ new Set();
6014
6070
  for (const id of this.points.keys()) {
6015
6071
  const loc = locOf.get(id);
@@ -6043,6 +6099,17 @@ var EmsKernel = class extends FlowEngine {
6043
6099
  * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
6044
6100
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
6045
6101
  */
6102
+ /**
6103
+ * 계측·설비 상태는 **에너지 어휘로** 받는다(물품 투영으로 보내지 않는다).
6104
+ *
6105
+ * ── 관측 시각을 함께 민다 (2026-08-18) ──────────────────────────────────────
6106
+ * 이 두 문은 투영기(`ObservedReducer`)를 거치지 않는다. 그래서 「마지막으로 들은 시각」이 갱신되지
6107
+ * 않았고, 초당 여러 건의 계측을 받는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼다**: 커널의
6108
+ * "지금" 이 시뮬 기준시각(2026-01-01)에 얼어, 구간 마감·피크·감축 제안이 전부 8개월 전으로 기록됐다.
6109
+ *
6110
+ * 계측 자체는 원천 시각을 달고 있어서 화면의 순간값은 정상으로 보였다 — 그래서 조용한 결함이었다.
6111
+ * 저널을 기간으로 접는 쪽(성과·요금·피더 배분)은 그 트윈에서 영원히 아무것도 찾지 못했다.
6112
+ */
6046
6113
  apply(envelope) {
6047
6114
  if (envelope.eventType === ENERGY_EVENT.measured) {
6048
6115
  this.ingestMeasured(envelope);
@@ -6087,6 +6154,8 @@ var EmsKernel = class extends FlowEngine {
6087
6154
  put("minKW");
6088
6155
  put("position");
6089
6156
  if (d?.at) eq.measuredAt = d.at;
6157
+ const heardAt = Date.parse(String(d?.at ?? envelope.eventTime ?? ""));
6158
+ if (Number.isFinite(heardAt)) this.noteObserved(heardAt);
6090
6159
  this.revision++;
6091
6160
  }
6092
6161
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
@@ -6097,6 +6166,7 @@ var EmsKernel = class extends FlowEngine {
6097
6166
  if (atMs === void 0) throw new Error("energy.measured has no usable time (data.at / eventTime) \u2014 cannot place it in a demand window");
6098
6167
  const id = String(d?.meterId ?? "").trim();
6099
6168
  if (!id) throw new Error("energy.measured has no meterId \u2014 a measurement with no meter cannot be accumulated");
6169
+ this.noteObserved(atMs);
6100
6170
  this.closeDue(atMs);
6101
6171
  this.openWindow(atMs);
6102
6172
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
@@ -6107,6 +6177,7 @@ var EmsKernel = class extends FlowEngine {
6107
6177
  if (Number.isFinite(Number(d?.kWh))) point.kWh = Number(d.kWh);
6108
6178
  if (Number.isFinite(Number(d?.powerFactor))) point.powerFactor = Number(d.powerFactor);
6109
6179
  }
6180
+ if (point.kWhAtWindowStart === void 0 && point.kWh !== void 0) point.kWhAtWindowStart = point.kWh;
6110
6181
  point.samplesInWindow++;
6111
6182
  this.points.set(id, point);
6112
6183
  const w = this.open;
@@ -6125,7 +6196,10 @@ var EmsKernel = class extends FlowEngine {
6125
6196
  if (this.open?.startMs === start) return;
6126
6197
  const contractKW = this.declaredContractKW();
6127
6198
  this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...contractKW !== void 0 ? { contractKW } : {} };
6128
- for (const p of this.points.values()) p.samplesInWindow = 0;
6199
+ for (const p of this.points.values()) {
6200
+ p.samplesInWindow = 0;
6201
+ p.kWhAtWindowStart = p.kWh;
6202
+ }
6129
6203
  this.suggestedFor = void 0;
6130
6204
  }
6131
6205
  /**
@@ -6142,6 +6216,13 @@ var EmsKernel = class extends FlowEngine {
6142
6216
  while (this.open && nowMs >= this.open.endMs) {
6143
6217
  const w = this.open;
6144
6218
  if (w.contractKW !== void 0 && w.maxKW !== void 0) w.overContract = w.maxKW > w.contractKW;
6219
+ const roots = this.rootMeterIds();
6220
+ const pointShares = [...this.points.values()].map((p) => {
6221
+ const from = p.kWhAtWindowStart;
6222
+ const to = p.kWh;
6223
+ const kWh = from !== void 0 && to !== void 0 && to >= from ? to - from : void 0;
6224
+ return { id: p.id, ...kWh !== void 0 ? { kWh } : {}, ...roots.has(p.id) ? { root: true } : {} };
6225
+ }).filter((p) => p.kWh !== void 0);
6145
6226
  this.closed.push(w);
6146
6227
  this.closedTotal++;
6147
6228
  if (this.closed.length > KEEP_CLOSED) this.closed.splice(0, this.closed.length - KEEP_CLOSED);
@@ -6165,6 +6246,22 @@ var EmsKernel = class extends FlowEngine {
6165
6246
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
6166
6247
  */
6167
6248
  ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {},
6249
+ /*
6250
+ * ── 지점별 몫을 함께 낸다 (2026-08-18) ────────────────────────────────
6251
+ *
6252
+ * 분기마다 계량기가 있는 현장에서 분기별 전력량은 **측정**이다. 그런데 그 사실이 저널에
6253
+ * 없어서, 저널을 접는 쪽은 가동시간으로 **배분**할 수밖에 없었다 — 계량이 있는데 배분으로
6254
+ * 말하는 것은 근거를 실제보다 약하게 보고하는 것이고, 그 위에 세운 SEU 판단이 무거운 값을 놓친다.
6255
+ *
6256
+ * 표본을 그대로 저널에 흘리는 대신 **구간마다 한 줄**로 낸다: 계측은 초당 여러 건이라 표본을
6257
+ * 저널에서 읽게 하면 조회 상한에 먼저 걸린다(호스트가 실제로 그 벽에 부딪혔다).
6258
+ *
6259
+ * 값은 적산의 뺄셈이다 — 계량기 자신의 회계이고, 그것이 요금의 근거다. 적산을 주지 않는
6260
+ * 지점은 **빠진다**(우리가 적분한 수를 계량기가 잰 것처럼 내지 않는다).
6261
+ *
6262
+ * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
6263
+ */
6264
+ ...pointShares.length ? { points: pointShares } : {},
6168
6265
  /*
6169
6266
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
6170
6267
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -6603,7 +6700,7 @@ function attributeEnergy(opts) {
6603
6700
  }
6604
6701
  const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
6605
6702
  if (!covered.length) {
6606
- unattributed.push({ poolMeterId: pool.meterId, kWh, reason: "no-consumers" });
6703
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? "not-submetered" : "no-consumers" });
6607
6704
  continue;
6608
6705
  }
6609
6706
  if (covered.length === 1) {
@@ -6860,6 +6957,7 @@ function retiredVocabularyIn(line) {
6860
6957
  documentPath,
6861
6958
  dueStatusOf,
6862
6959
  effectivityAt,
6960
+ electricalUpstreamOf,
6863
6961
  electricityCost,
6864
6962
  energyIntensity,
6865
6963
  energyOfWindows,
@@ -6875,6 +6973,7 @@ function retiredVocabularyIn(line) {
6875
6973
  ingest,
6876
6974
  ingestEnergyEquipmentRecords,
6877
6975
  ingestEnergyRecords,
6976
+ isElectricalLocationType,
6878
6977
  isEnergyEquipmentRecord,
6879
6978
  isEnergyRecord,
6880
6979
  isEquipmentLevel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.18",
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": {