@operato/twin-kernel 0.7.38 → 0.7.39

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.
@@ -1245,6 +1245,20 @@ export interface Attention {
1245
1245
  /** 계량 지점의 마지막 관측 — 값이 없으면 **모르는 것이다**(0 이 아니다). */
1246
1246
  export interface MeterPointState {
1247
1247
  id: string;
1248
+ /**
1249
+ * 이 계량 지점이 **모델에 선언된 것인가, 계측이 처음 데려온 것인가** (자리와 같은 어휘).
1250
+ *
1251
+ * ── 왜 표시해야 하나 ───────────────────────────────────────────────────────
1252
+ * 계측은 모르는 `meterId` 를 만나면 **조용히 새 지점을 만든다.** 그 kW 는 그 현장의 수요·피크에
1253
+ * 그대로 더해지므로(그것이 옳다 — 빼면 「계약 안쪽」이라는 더 위험한 거짓이 된다), 표시가 없으면
1254
+ * 커넥터 매핑에서 id 를 한 글자 틀린 것과 정상을 구별할 수 없다. 그때 진짜 계량기는 조용히 멈추고
1255
+ * 유령이 누적되며, **숫자는 그럴듯하고 틀린다.** 요금 판정까지 가는 축이라 자리 오타보다 무겁다.
1256
+ *
1257
+ * `'master'` = 모델(설비 선언)에 있는 계량기. `'observed'` = 계측만 데려온 계량기.
1258
+ * 옛 스냅샷에는 이 칸이 없다(`undefined`) — 「선언되었다」로 읽지 말 것. 선언 여부의 정본은 모델이고,
1259
+ * 커널은 표본이 올 때마다 이 값을 다시 맞춘다.
1260
+ */
1261
+ origin?: 'master' | 'observed';
1248
1262
  kW?: number;
1249
1263
  /** 누적 전력량(원천이 준 적산값) — 우리가 적분한 값이 아니다. */
1250
1264
  kWh?: number;
@@ -1265,6 +1279,18 @@ export interface MeterPointState {
1265
1279
  export interface DemandWindowState {
1266
1280
  startMs: number;
1267
1281
  endMs: number;
1282
+ /**
1283
+ * `maxKW` 가 찍힌 **그 순간**의, **모델이 선언한** 뿌리 계량기들만의 합.
1284
+ *
1285
+ * `maxKW` 는 모르는 계량기까지 포함한 합이다(그것을 빼면 부하가 조용히 작아지고, 작아진 부하는
1286
+ * 「계약 안쪽」이라는 더 위험한 거짓을 만든다). 그러나 그 값 하나만 내면 반대쪽 거짓이 생긴다 —
1287
+ * 오타로 생긴 유령 계량기가 계약 초과를 만들어도 화면은 그것을 진짜 초과로 말한다.
1288
+ *
1289
+ * 그래서 **두 벌을 낸다.** 화면은 「선언된 것으로는 82%, 정체 모를 계량기까지 넣으면 104%」라고
1290
+ * 말할 수 있다 — 빼서 정확한 척하지 않고, 더해서 뭉개지도 않는다. 두 값이 같으면 모든 뿌리가
1291
+ * 선언된 것이고, 그것도 사실이다. 잰 적이 없으면 `undefined` 다(0 이 아니다).
1292
+ */
1293
+ maxDeclaredKW?: number;
1268
1294
  /**
1269
1295
  * 이 구간의 **최대가 찍힌 순간**의 지점별 kW — 요금이 걸린 수는 kW 다.
1270
1296
  *
@@ -286,6 +286,20 @@ export class EmsKernel extends FlowEngine {
286
286
  this.openWindow(atMs);
287
287
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : undefined;
288
288
  const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
289
+ /*
290
+ * ── 이 계량기가 모델에 있는가를 **표시한다** (2026-08-20) ────────────────────
291
+ *
292
+ * 모르는 `meterId` 는 여기서 조용히 새 지점이 된다. 그 kW 는 수요·피크에 그대로 더해지는데(그것이
293
+ * 옳다 — 아래 뿌리 합 주석), 표시가 없으면 커넥터 매핑의 한 글자 오타와 정상을 구별할 수 없다.
294
+ * 그때 진짜 계량기는 조용히 멈추고 유령이 누적되며, 숫자는 그럴듯하고 틀린다.
295
+ *
296
+ * 바로 옆 `applyEquipmentEnergy` 는 같은 상황을 이미 세어 두고 있었다(`unknownEquipmentReports`).
297
+ * 계량기만 그 규율에서 빠져 있었다 — 요금까지 가는 축이라 오히려 더 무거운 자리다.
298
+ *
299
+ * **표본마다 다시 맞춘다**: 선언 여부의 정본은 모델이고, 모델은 채택(adopt)으로 늘어난다. 한 번
300
+ * 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다. 자리(`origin: 'observed'`)와 같은 어휘다.
301
+ */
302
+ point.origin = this.equipment.has(id) ? 'master' : 'observed';
289
303
  /* 늦게 온 옛 표본이 최신 관측을 덮지 않게 — 상위 커널의 `stale` 판정과 같은 규율. */
290
304
  if (point.atMs === undefined || atMs >= point.atMs) {
291
305
  point.atMs = atMs;
@@ -324,9 +338,33 @@ export class EmsKernel extends FlowEngine {
324
338
  * 조용히 작아지고, 작아진 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
325
339
  */
326
340
  const roots = this.rootMeterIds();
327
- const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
341
+ /*
342
+ * ── 합을 **두 벌** 낸다 (2026-08-20) ──────────────────────────────────────
343
+ *
344
+ * 전부의 합(`total`)이 요금 판정의 근거다 — 모르는 계량기를 빼면 부하가 조용히 작아지고, 작아진
345
+ * 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
346
+ *
347
+ * 그런데 그 값 하나만 내면 반대쪽 거짓이 생긴다: 오타로 생긴 유령 계량기가 만든 초과를 화면이
348
+ * 진짜 초과로 말한다. 그래서 **선언된 뿌리만의 합**도 같은 순간에 잰다. 화면은 두 수를 나란히
349
+ * 놓고 「선언된 것으로는 82%, 정체 모를 계량기까지 넣으면 104%」라고 말할 수 있다.
350
+ *
351
+ * 선언 여부는 **모델에 직접 묻는다**(`this.equipment`). 지점에 찍힌 `origin` 을 쓰지 않는 이유는
352
+ * 옛 스냅샷에서 이어받은 지점에는 그 칸이 없고, 없는 것을 「선언되었다」로 읽으면 이 값이 조용히
353
+ * 전부의 합과 같아지기 때문이다.
354
+ */
355
+ let total = 0;
356
+ let declared = 0;
357
+ for (const p of this.points.values()) {
358
+ if (!roots.has(p.id))
359
+ continue;
360
+ const v = p.kW ?? 0;
361
+ total += v;
362
+ if (this.equipment.has(p.id))
363
+ declared += v;
364
+ }
328
365
  if (w.maxKW === undefined || total > w.maxKW) {
329
366
  w.maxKW = total;
367
+ w.maxDeclaredKW = declared;
330
368
  /*
331
369
  * ── 피크가 갱신된 **그 순간**의 지점별 kW 를 붙잡는다 (2026-08-18) ─────────
332
370
  *
@@ -6680,6 +6680,7 @@ var EmsKernel = class extends FlowEngine {
6680
6680
  this.openWindow(atMs);
6681
6681
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
6682
6682
  const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
6683
+ point.origin = this.equipment.has(id) ? "master" : "observed";
6683
6684
  if (point.atMs === void 0 || atMs >= point.atMs) {
6684
6685
  point.atMs = atMs;
6685
6686
  if (kW !== void 0) point.kW = kW;
@@ -6692,9 +6693,17 @@ var EmsKernel = class extends FlowEngine {
6692
6693
  const w = this.open;
6693
6694
  if (kW !== void 0) {
6694
6695
  const roots = this.rootMeterIds();
6695
- const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
6696
+ let total = 0;
6697
+ let declared = 0;
6698
+ for (const p of this.points.values()) {
6699
+ if (!roots.has(p.id)) continue;
6700
+ const v = p.kW ?? 0;
6701
+ total += v;
6702
+ if (this.equipment.has(p.id)) declared += v;
6703
+ }
6696
6704
  if (w.maxKW === void 0 || total > w.maxKW) {
6697
6705
  w.maxKW = total;
6706
+ w.maxDeclaredKW = declared;
6698
6707
  w.pointsAtPeak = [...this.points.values()].filter((p) => Number.isFinite(Number(p.kW))).map((p) => ({ id: p.id, kW: Number(p.kW) }));
6699
6708
  }
6700
6709
  w.samples++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.38",
3
+ "version": "0.7.39",
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": {