@operato/twin-kernel 0.7.18 → 0.7.19

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.
@@ -1206,6 +1206,14 @@ export interface MeterPointState {
1206
1206
  atMs?: number;
1207
1207
  /** 이 구간에 받은 표본 수 — 「못 쟀다」와 「0이었다」를 구별한다. */
1208
1208
  samplesInWindow: number;
1209
+ /**
1210
+ * 이 구간이 열릴 때의 적산값 — **구간 전력량을 뺄셈으로 얻기 위한 기준점.**
1211
+ *
1212
+ * 지점별 구간 전력량은 「끝 적산 − 시작 적산」이다. 그것을 우리가 kW 로 적분하지 않는 이유는,
1213
+ * 적산은 **계량기 자신의 회계**이고 그것이 요금의 근거이기 때문이다(우리가 적분한 값과 미세하게
1214
+ * 다르며, 다를 때 맞는 쪽은 계량기다).
1215
+ */
1216
+ kWhAtWindowStart?: number;
1209
1217
  }
1210
1218
  export interface DemandWindowState {
1211
1219
  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
  * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
@@ -168,6 +168,17 @@ export class EmsKernel extends FlowEngine {
168
168
  * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
169
169
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
170
170
  */
171
+ /**
172
+ * 계측·설비 상태는 **에너지 어휘로** 받는다(물품 투영으로 보내지 않는다).
173
+ *
174
+ * ── 관측 시각을 함께 민다 (2026-08-18) ──────────────────────────────────────
175
+ * 이 두 문은 투영기(`ObservedReducer`)를 거치지 않는다. 그래서 「마지막으로 들은 시각」이 갱신되지
176
+ * 않았고, 초당 여러 건의 계측을 받는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼다**: 커널의
177
+ * "지금" 이 시뮬 기준시각(2026-01-01)에 얼어, 구간 마감·피크·감축 제안이 전부 8개월 전으로 기록됐다.
178
+ *
179
+ * 계측 자체는 원천 시각을 달고 있어서 화면의 순간값은 정상으로 보였다 — 그래서 조용한 결함이었다.
180
+ * 저널을 기간으로 접는 쪽(성과·요금·피더 배분)은 그 트윈에서 영원히 아무것도 찾지 못했다.
181
+ */
171
182
  apply(envelope) {
172
183
  if (envelope.eventType === ENERGY_EVENT.measured) {
173
184
  this.ingestMeasured(envelope);
@@ -215,6 +226,10 @@ export class EmsKernel extends FlowEngine {
215
226
  /* **값과 함께 시각을 남긴다** — 없으면 화면이 멈춘 값을 지금 값으로 그린다(계량 지점과 같은 규율). */
216
227
  if (d?.at)
217
228
  eq.measuredAt = d.at;
229
+ /* 설비 상태도 관측이다 — 계측과 같은 규칙으로 「마지막으로 들은 시각」을 민다(규칙 한 벌). */
230
+ const heardAt = Date.parse(String(d?.at ?? envelope.eventTime ?? ''));
231
+ if (Number.isFinite(heardAt))
232
+ this.noteObserved(heardAt);
218
233
  this.revision++;
219
234
  }
220
235
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
@@ -231,6 +246,8 @@ export class EmsKernel extends FlowEngine {
231
246
  const id = String(d?.meterId ?? '').trim();
232
247
  if (!id)
233
248
  throw new Error('energy.measured has no meterId — a measurement with no meter cannot be accumulated');
249
+ /* 들은 시각을 먼저 민다 — 이 뒤에 나오는 사실(구간 마감·피크)의 시각이 여기서 나온다. */
250
+ this.noteObserved(atMs);
234
251
  /* 이 표본이 새 구간의 것이면 앞 구간을 먼저 닫는다 — 마감이 표본보다 늦으면 최대가 섞인다. */
235
252
  this.closeDue(atMs);
236
253
  this.openWindow(atMs);
@@ -246,6 +263,15 @@ export class EmsKernel extends FlowEngine {
246
263
  if (Number.isFinite(Number(d?.powerFactor)))
247
264
  point.powerFactor = Number(d.powerFactor);
248
265
  }
266
+ /*
267
+ * 기준점이 아직 없으면 **이 구간의 첫 적산**으로 심는다.
268
+ *
269
+ * 구간이 열릴 때 심는 것이 원칙이다(그러면 구간 경계에서 전기가 새지 않는다 — 앞 구간의 마지막
270
+ * 적산이 이어진다). 그런데 처음 보는 지점은 그때 아직 존재하지 않으므로 심을 값이 없다. 그 지점의
271
+ * **첫 구간만** 첫 표본을 기준으로 삼는다 — 그 앞의 소비는 우리가 듣지 못한 것이고, 지어내지 않는다.
272
+ */
273
+ if (point.kWhAtWindowStart === undefined && point.kWh !== undefined)
274
+ point.kWhAtWindowStart = point.kWh;
249
275
  point.samplesInWindow++;
250
276
  this.points.set(id, point);
251
277
  const w = this.open;
@@ -280,9 +306,12 @@ export class EmsKernel extends FlowEngine {
280
306
  return;
281
307
  const contractKW = this.declaredContractKW();
282
308
  this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...(contractKW !== undefined ? { contractKW } : {}) };
283
- /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다. */
284
- for (const p of this.points.values())
309
+ /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다.
310
+ 적산 기준점도 함께 찍는다 — 이 구간의 지점별 전력량은 「끝 − 시작」으로 나온다. */
311
+ for (const p of this.points.values()) {
285
312
  p.samplesInWindow = 0;
313
+ p.kWhAtWindowStart = p.kWh;
314
+ }
286
315
  this.suggestedFor = undefined;
287
316
  }
288
317
  /**
@@ -300,6 +329,16 @@ export class EmsKernel extends FlowEngine {
300
329
  const w = this.open;
301
330
  if (w.contractKW !== undefined && w.maxKW !== undefined)
302
331
  w.overContract = w.maxKW > w.contractKW;
332
+ /* 지점별 구간 전력량 — 적산의 뺄셈. 기준점이 없거나 적산이 줄었으면(계기 교체) 내지 않는다. */
333
+ const roots = this.rootMeterIds();
334
+ const pointShares = [...this.points.values()]
335
+ .map(p => {
336
+ const from = p.kWhAtWindowStart;
337
+ const to = p.kWh;
338
+ const kWh = from !== undefined && to !== undefined && to >= from ? to - from : undefined;
339
+ return { id: p.id, ...(kWh !== undefined ? { kWh } : {}), ...(roots.has(p.id) ? { root: true } : {}) };
340
+ })
341
+ .filter(p => p.kWh !== undefined);
303
342
  this.closed.push(w);
304
343
  this.closedTotal++;
305
344
  if (this.closed.length > KEEP_CLOSED)
@@ -324,6 +363,22 @@ export class EmsKernel extends FlowEngine {
324
363
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
325
364
  */
326
365
  ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {}),
366
+ /*
367
+ * ── 지점별 몫을 함께 낸다 (2026-08-18) ────────────────────────────────
368
+ *
369
+ * 분기마다 계량기가 있는 현장에서 분기별 전력량은 **측정**이다. 그런데 그 사실이 저널에
370
+ * 없어서, 저널을 접는 쪽은 가동시간으로 **배분**할 수밖에 없었다 — 계량이 있는데 배분으로
371
+ * 말하는 것은 근거를 실제보다 약하게 보고하는 것이고, 그 위에 세운 SEU 판단이 무거운 값을 놓친다.
372
+ *
373
+ * 표본을 그대로 저널에 흘리는 대신 **구간마다 한 줄**로 낸다: 계측은 초당 여러 건이라 표본을
374
+ * 저널에서 읽게 하면 조회 상한에 먼저 걸린다(호스트가 실제로 그 벽에 부딪혔다).
375
+ *
376
+ * 값은 적산의 뺄셈이다 — 계량기 자신의 회계이고, 그것이 요금의 근거다. 적산을 주지 않는
377
+ * 지점은 **빠진다**(우리가 적분한 수를 계량기가 잰 것처럼 내지 않는다).
378
+ *
379
+ * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
380
+ */
381
+ ...(pointShares.length ? { points: pointShares } : {}),
327
382
  /*
328
383
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
329
384
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -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)
@@ -1515,6 +1515,25 @@ var ObservedReducer = class {
1515
1515
  get lastObservedMs() {
1516
1516
  return this.observedAtMs;
1517
1517
  }
1518
+ /**
1519
+ * 관측 시각을 앞으로 민다 — **이 문을 거치지 않는 관측이 있다.**
1520
+ *
1521
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
1522
+ * 에너지 커널은 계측(`energy.measured`)을 이 리듀서 **앞에서** 가로채 자기 구간에 누적한다(물류 투영과
1523
+ * 어휘가 다르므로 옳다). 그런데 그 경로가 「마지막으로 들은 시각」을 갱신하지 않았다. 그래서 계측이
1524
+ * 초당 여러 건 들어오는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼고**, 커널의 "지금" 이
1525
+ * 시뮬 기준시각(BASE_EPOCH, 2026-01-01)에 얼어 있었다.
1526
+ *
1527
+ * 결과는 조용했다: 구간 마감·피크 같은 **커널이 낸 사실이 8개월 전으로 기록**되어, 저널을 기간으로
1528
+ * 접는 소비처(성과·요금·피더 배분)가 그 트윈에서 영원히 아무것도 찾지 못했다. 계측 자체는 원천 시각을
1529
+ * 달고 있었으므로 화면의 순간값만 정상으로 보였다 — 그래서 눈에 띄지 않았다.
1530
+ *
1531
+ * 시각을 되돌리지는 않는다: 늦게 도착한 옛 표본이 "지금" 을 과거로 끌면 그 뒤 판정이 전부 흔들린다.
1532
+ */
1533
+ noteObservedAt(atMs) {
1534
+ if (!Number.isFinite(atMs)) return;
1535
+ if (this.observedAtMs === void 0 || atMs > this.observedAtMs) this.observedAtMs = atMs;
1536
+ }
1518
1537
  /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
1519
1538
  rememberShift(key, r) {
1520
1539
  if (!r.workCalendar?.length && !r.window) return;
@@ -3799,10 +3818,16 @@ var FlowEngine = class {
3799
3818
  *
3800
3819
  * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
3801
3820
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
3821
+ *
3822
+ * 들은 시각의 출처가 **둘**이다: 물류 투영기(`ObservedReducer`)와, 도메인 어휘로 직접 받는 경로
3823
+ * (`noteObserved` — 에너지 계측). 둘 중 **나중 것**을 쓴다: 한쪽만 보면 그 트윈이 실제로 듣고 있는데도
3824
+ * 「아무것도 못 들었다」가 되어, 커널이 내는 사실이 시뮬 기준시각(2026-01-01)으로 기록된다.
3802
3825
  */
3803
3826
  nowMs() {
3804
- const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
3805
- return observed ?? this.originMs + this.clockMs;
3827
+ if (!this.observeMode) return this.originMs + this.clockMs;
3828
+ const fromProjection = this.observer?.lastObservedMs;
3829
+ const heard = [fromProjection, this.heardAtMs].filter((v) => Number.isFinite(v));
3830
+ return heard.length ? Math.max(...heard) : this.originMs + this.clockMs;
3806
3831
  }
3807
3832
  /**
3808
3833
  * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
@@ -3893,6 +3918,22 @@ var FlowEngine = class {
3893
3918
  observedHandlers() {
3894
3919
  return this.handlersRef();
3895
3920
  }
3921
+ /**
3922
+ * 관측 시각을 앞으로 민다 — **투영기를 거치지 않는 관측을 위한 문.**
3923
+ *
3924
+ * 도메인 커널이 자기 어휘의 관측을 `apply()` 에서 먼저 처리하고 돌아가는 경우가 있다(에너지 계측이
3925
+ * 그렇다 — 물품 투영과 어휘가 다르므로 옳다). 그때도 「마지막으로 들은 시각」은 갱신돼야 한다:
3926
+ * 그것이 관측 구동 커널의 **지금**이고, 커널이 내는 모든 사실의 시각이 여기서 나온다.
3927
+ *
3928
+ * 이 문이 없던 동안 미러 에너지 트윈의 사실들이 시뮬 기준시각으로 기록됐다([[noteObservedAt]]).
3929
+ */
3930
+ noteObserved(atMs) {
3931
+ if (!Number.isFinite(atMs)) return;
3932
+ this.observer?.noteObservedAt(atMs);
3933
+ if (this.heardAtMs === void 0 || atMs > this.heardAtMs) this.heardAtMs = atMs;
3934
+ }
3935
+ /** 도메인 어휘로 직접 들은 마지막 관측 시각 — 투영기를 거치지 않는 경로의 「지금」. */
3936
+ heardAtMs;
3896
3937
  /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
3897
3938
  get observing() {
3898
3939
  return this.observeMode;
@@ -6043,6 +6084,17 @@ var EmsKernel = class extends FlowEngine {
6043
6084
  * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
6044
6085
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
6045
6086
  */
6087
+ /**
6088
+ * 계측·설비 상태는 **에너지 어휘로** 받는다(물품 투영으로 보내지 않는다).
6089
+ *
6090
+ * ── 관측 시각을 함께 민다 (2026-08-18) ──────────────────────────────────────
6091
+ * 이 두 문은 투영기(`ObservedReducer`)를 거치지 않는다. 그래서 「마지막으로 들은 시각」이 갱신되지
6092
+ * 않았고, 초당 여러 건의 계측을 받는 미러 트윈이 **자기가 아무것도 못 들었다고 여겼다**: 커널의
6093
+ * "지금" 이 시뮬 기준시각(2026-01-01)에 얼어, 구간 마감·피크·감축 제안이 전부 8개월 전으로 기록됐다.
6094
+ *
6095
+ * 계측 자체는 원천 시각을 달고 있어서 화면의 순간값은 정상으로 보였다 — 그래서 조용한 결함이었다.
6096
+ * 저널을 기간으로 접는 쪽(성과·요금·피더 배분)은 그 트윈에서 영원히 아무것도 찾지 못했다.
6097
+ */
6046
6098
  apply(envelope) {
6047
6099
  if (envelope.eventType === ENERGY_EVENT.measured) {
6048
6100
  this.ingestMeasured(envelope);
@@ -6087,6 +6139,8 @@ var EmsKernel = class extends FlowEngine {
6087
6139
  put("minKW");
6088
6140
  put("position");
6089
6141
  if (d?.at) eq.measuredAt = d.at;
6142
+ const heardAt = Date.parse(String(d?.at ?? envelope.eventTime ?? ""));
6143
+ if (Number.isFinite(heardAt)) this.noteObserved(heardAt);
6090
6144
  this.revision++;
6091
6145
  }
6092
6146
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
@@ -6097,6 +6151,7 @@ var EmsKernel = class extends FlowEngine {
6097
6151
  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
6152
  const id = String(d?.meterId ?? "").trim();
6099
6153
  if (!id) throw new Error("energy.measured has no meterId \u2014 a measurement with no meter cannot be accumulated");
6154
+ this.noteObserved(atMs);
6100
6155
  this.closeDue(atMs);
6101
6156
  this.openWindow(atMs);
6102
6157
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
@@ -6107,6 +6162,7 @@ var EmsKernel = class extends FlowEngine {
6107
6162
  if (Number.isFinite(Number(d?.kWh))) point.kWh = Number(d.kWh);
6108
6163
  if (Number.isFinite(Number(d?.powerFactor))) point.powerFactor = Number(d.powerFactor);
6109
6164
  }
6165
+ if (point.kWhAtWindowStart === void 0 && point.kWh !== void 0) point.kWhAtWindowStart = point.kWh;
6110
6166
  point.samplesInWindow++;
6111
6167
  this.points.set(id, point);
6112
6168
  const w = this.open;
@@ -6125,7 +6181,10 @@ var EmsKernel = class extends FlowEngine {
6125
6181
  if (this.open?.startMs === start) return;
6126
6182
  const contractKW = this.declaredContractKW();
6127
6183
  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;
6184
+ for (const p of this.points.values()) {
6185
+ p.samplesInWindow = 0;
6186
+ p.kWhAtWindowStart = p.kWh;
6187
+ }
6129
6188
  this.suggestedFor = void 0;
6130
6189
  }
6131
6190
  /**
@@ -6142,6 +6201,13 @@ var EmsKernel = class extends FlowEngine {
6142
6201
  while (this.open && nowMs >= this.open.endMs) {
6143
6202
  const w = this.open;
6144
6203
  if (w.contractKW !== void 0 && w.maxKW !== void 0) w.overContract = w.maxKW > w.contractKW;
6204
+ const roots = this.rootMeterIds();
6205
+ const pointShares = [...this.points.values()].map((p) => {
6206
+ const from = p.kWhAtWindowStart;
6207
+ const to = p.kWh;
6208
+ const kWh = from !== void 0 && to !== void 0 && to >= from ? to - from : void 0;
6209
+ return { id: p.id, ...kWh !== void 0 ? { kWh } : {}, ...roots.has(p.id) ? { root: true } : {} };
6210
+ }).filter((p) => p.kWh !== void 0);
6145
6211
  this.closed.push(w);
6146
6212
  this.closedTotal++;
6147
6213
  if (this.closed.length > KEEP_CLOSED) this.closed.splice(0, this.closed.length - KEEP_CLOSED);
@@ -6165,6 +6231,22 @@ var EmsKernel = class extends FlowEngine {
6165
6231
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
6166
6232
  */
6167
6233
  ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {},
6234
+ /*
6235
+ * ── 지점별 몫을 함께 낸다 (2026-08-18) ────────────────────────────────
6236
+ *
6237
+ * 분기마다 계량기가 있는 현장에서 분기별 전력량은 **측정**이다. 그런데 그 사실이 저널에
6238
+ * 없어서, 저널을 접는 쪽은 가동시간으로 **배분**할 수밖에 없었다 — 계량이 있는데 배분으로
6239
+ * 말하는 것은 근거를 실제보다 약하게 보고하는 것이고, 그 위에 세운 SEU 판단이 무거운 값을 놓친다.
6240
+ *
6241
+ * 표본을 그대로 저널에 흘리는 대신 **구간마다 한 줄**로 낸다: 계측은 초당 여러 건이라 표본을
6242
+ * 저널에서 읽게 하면 조회 상한에 먼저 걸린다(호스트가 실제로 그 벽에 부딪혔다).
6243
+ *
6244
+ * 값은 적산의 뺄셈이다 — 계량기 자신의 회계이고, 그것이 요금의 근거다. 적산을 주지 않는
6245
+ * 지점은 **빠진다**(우리가 적분한 수를 계량기가 잰 것처럼 내지 않는다).
6246
+ *
6247
+ * `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
6248
+ */
6249
+ ...pointShares.length ? { points: pointShares } : {},
6168
6250
  /*
6169
6251
  * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
6170
6252
  * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
@@ -6603,7 +6685,7 @@ function attributeEnergy(opts) {
6603
6685
  }
6604
6686
  const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
6605
6687
  if (!covered.length) {
6606
- unattributed.push({ poolMeterId: pool.meterId, kWh, reason: "no-consumers" });
6688
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? "not-submetered" : "no-consumers" });
6607
6689
  continue;
6608
6690
  }
6609
6691
  if (covered.length === 1) {
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.19",
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": {