@operato/twin-kernel 0.7.3 → 0.7.5

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.
@@ -1193,6 +1193,14 @@ export interface DemandWindowState {
1193
1193
  /** 계약전력 대비 — 계약을 모르면 `undefined`(짐작하지 않는다). */
1194
1194
  contractKW?: number;
1195
1195
  overContract?: boolean;
1196
+ /**
1197
+ * 이 구간의 값이 **잰 것이 아니라 만든 것**임을 밝힌다(시뮬레이션).
1198
+ *
1199
+ * 시뮬 트윈은 선언된 부하 계수와 설비 가동 상태로 부하를 계산한다. 그 수를 계측과 같은 자리에
1200
+ * 두면 화면·성과·보고서가 그것을 실측으로 읽는다 — 그래서 값 옆에 종류를 함께 싣는다.
1201
+ * 미러 트윈에서는 절대 켜지지 않는다(관측 구동은 잰 것만 쓴다).
1202
+ */
1203
+ derived?: boolean;
1196
1204
  }
1197
1205
  export interface EnergyState {
1198
1206
  points: MeterPointState[];
@@ -1842,6 +1850,14 @@ export interface TwinKernel {
1842
1850
  adoptStructure(def: TwinModelDef): StructureShift;
1843
1851
  /** 이 커널이 관측으로 구동됨을 선언한다 — 첫 이벤트가 오기 전에도 그렇다. */
1844
1852
  observe(): void;
1853
+ /**
1854
+ * 시각 기준점을 세운다 — 이 트윈의 「지금」은 `기준점 + 경과`다.
1855
+ *
1856
+ * 세우는 쪽이 기동 순간의 실제 시각을 준다. 주지 않으면 커널의 기본 기준점을 쓰는데, 그러면 시뮬
1857
+ * 트윈의 저널 시각이 재기동마다 되감겨 「언제 있었던 일인가」를 되짚을 수 없다.
1858
+ * **기동 직후에만** 부른다 — 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋난다.
1859
+ */
1860
+ setClockOrigin?(originMs: number): void;
1845
1861
  getSnapshot(): StateSnapshot;
1846
1862
  onEvent(handler: EventHandler): Unsubscribe;
1847
1863
  dispatch(cmd: Command): CommandAck;
@@ -38,6 +38,22 @@ export declare class EmsKernel extends FlowEngine {
38
38
  * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
39
39
  * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
40
40
  */
41
+ /**
42
+ * 같은 현장 이웃 트윈의 **설비 가동 상태** — 부하를 만들 때 읽는다.
43
+ *
44
+ * ── 왜 커널이 읽나 (2026-08-14) ────────────────────────────────────────────
45
+ * 시뮬 EMS 트윈은 계측이 없으므로 스스로 전기를 만들어야 한다. 그런데 「무엇이 돌고 있나」는
46
+ * 공장 트윈이 아는 사실이다. 호스트는 **누가 같은 현장에 있는지**만 알려 주고, 무엇을 읽어
47
+ * 어떻게 부하로 바꿀지는 커널이 정한다 — 계수와 규칙이 호스트에 흩어지면 트윈마다 다른 답이 된다.
48
+ *
49
+ * 여기 실리는 것은 상태뿐이다(kW 가 아니다). kW 는 **이 트윈의 모델이 선언한 계수**로 커널이 만든다.
50
+ */
51
+ private peerStatus;
52
+ /** 이웃 설비 상태를 싣는다 — 매 tick 갱신을 전제로 통째로 바꾼다(사라진 설비가 남지 않게). */
53
+ observePeerEquipment(list: readonly {
54
+ id: string;
55
+ status?: string;
56
+ }[]): void;
41
57
  private declaredContractKW;
42
58
  /**
43
59
  * **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
@@ -86,6 +102,20 @@ export declare class EmsKernel extends FlowEngine {
86
102
  protected allocate(): void;
87
103
  protected onTaskComplete(): void;
88
104
  /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
105
+ /**
106
+ * 선언된 부하 계수 × 가동 상태 → **이 현장의 부하**. 시뮬레이션에서만 만든다.
107
+ *
108
+ * ── 규칙 ──────────────────────────────────────────────────────────────────
109
+ * ① 계수를 선언한 설비만 센다(`power.ratedKW`). 선언이 없으면 그 설비는 부하를 만들지 않는다 —
110
+ * 타입 기본값을 두지 않기로 했다(계수는 현장이 안다).
111
+ * ② 가동 여부는 **상태**로 판단한다. 자기 설비에 상태가 있으면 그것을, 없으면 이웃 트윈(같은 현장)의
112
+ * 상태를 본다. 어느 쪽에도 없으면 **모르는 것**이므로 그 설비는 이번 계산에서 빠진다.
113
+ * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
114
+ * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
115
+ */
116
+ private deriveLoad;
117
+ /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
118
+ private numberProperty;
89
119
  tick(dtMs: number): void;
90
120
  getSnapshot(): StateSnapshot;
91
121
  }
@@ -44,6 +44,15 @@ function windowMsOf(opts) {
44
44
  }
45
45
  /** 상태에 남기는 마감 구간 수 — 하루치(15분 × 96). 그 앞은 저널이 답한다. */
46
46
  const KEEP_CLOSED = 96;
47
+ /**
48
+ * 이 상태를 「돌고 있다」로 볼 것인가 — 커널의 상태 어휘를 그대로 쓴다.
49
+ *
50
+ * `busy`·`in-use` 는 일하는 중이고, `idle` 은 서 있는 중이며, `down` 은 고장이다. 셋 다 「돌지 않음」
51
+ * 쪽이지만 대기전력은 선언된 만큼 센다(그 판단은 부르는 쪽에 있다).
52
+ */
53
+ function isRunningStatus(status) {
54
+ return status === 'busy' || status === 'in-use' || status === 'running';
55
+ }
47
56
  export class EmsKernel extends FlowEngine {
48
57
  points = new Map();
49
58
  open;
@@ -78,6 +87,21 @@ export class EmsKernel extends FlowEngine {
78
87
  * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
79
88
  * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
80
89
  */
90
+ /**
91
+ * 같은 현장 이웃 트윈의 **설비 가동 상태** — 부하를 만들 때 읽는다.
92
+ *
93
+ * ── 왜 커널이 읽나 (2026-08-14) ────────────────────────────────────────────
94
+ * 시뮬 EMS 트윈은 계측이 없으므로 스스로 전기를 만들어야 한다. 그런데 「무엇이 돌고 있나」는
95
+ * 공장 트윈이 아는 사실이다. 호스트는 **누가 같은 현장에 있는지**만 알려 주고, 무엇을 읽어
96
+ * 어떻게 부하로 바꿀지는 커널이 정한다 — 계수와 규칙이 호스트에 흩어지면 트윈마다 다른 답이 된다.
97
+ *
98
+ * 여기 실리는 것은 상태뿐이다(kW 가 아니다). kW 는 **이 트윈의 모델이 선언한 계수**로 커널이 만든다.
99
+ */
100
+ peerStatus = new Map();
101
+ /** 이웃 설비 상태를 싣는다 — 매 tick 갱신을 전제로 통째로 바꾼다(사라진 설비가 남지 않게). */
102
+ observePeerEquipment(list) {
103
+ this.peerStatus = new Map(list.filter(e => e?.id).map(e => [String(e.id), String(e.status ?? '')]));
104
+ }
81
105
  declaredContractKW() {
82
106
  let max;
83
107
  for (const loc of this.boardDef?.locations ?? []) {
@@ -252,12 +276,23 @@ export class EmsKernel extends FlowEngine {
252
276
  * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
253
277
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
254
278
  */
255
- ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {})
279
+ ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {}),
280
+ /*
281
+ * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
282
+ * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
283
+ */
284
+ ...(w.derived ? { derived: true } : {})
256
285
  });
257
286
  /* 피크는 **마감된 구간**으로만 갱신한다 — 열린 구간의 최대는 아직 확정이 아니다. */
258
287
  if (w.maxKW !== undefined && (this.peak === undefined || w.maxKW > this.peak.kW)) {
259
288
  this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
260
- this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}) });
289
+ this.emitOp(ENERGY_EVENT.peak, {
290
+ kW: w.maxKW,
291
+ windowStartMs: w.startMs,
292
+ ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
293
+ /* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
294
+ ...(w.derived ? { derived: true } : {})
295
+ });
261
296
  }
262
297
  /* 다음 구간은 표본이 올 때 연다 — 미리 열면 오지 않은 구간을 존재하는 것처럼 만든다. */
263
298
  this.open = undefined;
@@ -330,9 +365,83 @@ export class EmsKernel extends FlowEngine {
330
365
  this.noteFlowRequest('onTaskComplete');
331
366
  }
332
367
  /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
368
+ /**
369
+ * 선언된 부하 계수 × 가동 상태 → **이 현장의 부하**. 시뮬레이션에서만 만든다.
370
+ *
371
+ * ── 규칙 ──────────────────────────────────────────────────────────────────
372
+ * ① 계수를 선언한 설비만 센다(`power.ratedKW`). 선언이 없으면 그 설비는 부하를 만들지 않는다 —
373
+ * 타입 기본값을 두지 않기로 했다(계수는 현장이 안다).
374
+ * ② 가동 여부는 **상태**로 판단한다. 자기 설비에 상태가 있으면 그것을, 없으면 이웃 트윈(같은 현장)의
375
+ * 상태를 본다. 어느 쪽에도 없으면 **모르는 것**이므로 그 설비는 이번 계산에서 빠진다.
376
+ * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
377
+ * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
378
+ */
379
+ deriveLoad(atMs) {
380
+ if (this.observing)
381
+ return; // 미러 — 계측이 진실이다
382
+ let total = 0;
383
+ let counted = 0;
384
+ for (const e of this.boardDef?.equipment ?? []) {
385
+ const rated = this.numberProperty(e, EMS_PROPERTY.ratedKW);
386
+ if (rated === undefined)
387
+ continue;
388
+ /*
389
+ * **이웃이 말한 상태가 먼저다.** 설비를 실제로 돌리는 것은 공정 트윈이고, 에너지 트윈의 자기
390
+ * 설비 상태는 모델을 실을 때의 씨앗값(`idle`)일 뿐이다. 자기 것을 먼저 보면 그 씨앗값이 이웃의
391
+ * 사실을 덮어, 공장이 도는데도 대기전력만 세는 일이 벌어진다(실제로 그렇게 났다).
392
+ */
393
+ const status = this.peerStatus.get(e.id) ?? this.equipment.get(e.id)?.status;
394
+ if (status === undefined || status === '')
395
+ continue; // 모르는 상태 — 지어내지 않는다
396
+ if (isRunningStatus(status)) {
397
+ total += rated;
398
+ counted++;
399
+ continue;
400
+ }
401
+ const standby = this.numberProperty(e, EMS_PROPERTY.standbyKW);
402
+ if (standby !== undefined) {
403
+ total += standby;
404
+ counted++;
405
+ }
406
+ }
407
+ if (!counted)
408
+ return; // 셀 것이 하나도 없으면 구간에 값을 넣지 않는다(0 을 주장하지 않는다)
409
+ this.closeDue(atMs);
410
+ this.openWindow(atMs);
411
+ const w = this.open;
412
+ w.derived = true;
413
+ if (w.maxKW === undefined || total > w.maxKW)
414
+ w.maxKW = total;
415
+ w.samples++;
416
+ w.meanKW = w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
417
+ this.revision++;
418
+ this.judgeOpenWindow(atMs);
419
+ }
420
+ /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
421
+ numberProperty(resource, id) {
422
+ for (const p of resource?.properties ?? []) {
423
+ if (p?.id !== id)
424
+ continue;
425
+ const v = Number(p.value);
426
+ if (Number.isFinite(v) && v >= 0)
427
+ return v;
428
+ }
429
+ return undefined;
430
+ }
333
431
  tick(dtMs) {
334
432
  super.tick(dtMs);
335
- this.closeDue(this.clockMs);
433
+ /*
434
+ * 구간은 **이 트윈의 「지금」**으로 잡는다(`nowMs`) — 경과 시간(`clockMs`)이 아니다.
435
+ *
436
+ * 예전에는 경과 시간을 그대로 넘겨서 시뮬 트윈의 수요 구간이 **1970년**에 섰다. 계측 경로는
437
+ * 계측이 말한 절대 시각을 쓰므로, 두 경로가 서로 다른 시간축에 값을 넣고 있었던 셈이다.
438
+ * 요금은 벽시계의 15분으로 매겨지니 그 경계에 서야 견줄 수 있다.
439
+ *
440
+ * 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
441
+ */
442
+ const at = this.nowMs();
443
+ this.deriveLoad(at);
444
+ this.closeDue(at);
336
445
  }
337
446
  getSnapshot() {
338
447
  const snap = super.getSnapshot();
@@ -12,5 +12,9 @@ export declare const EMS_EQUIPMENT_TYPES: readonly ["meter", "breaker", "pv-arra
12
12
  export declare const EMS_PROPERTY: {
13
13
  /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
14
14
  readonly contractKW: "contract.kW";
15
+ /** 가동 중 소비(kW). */
16
+ readonly ratedKW: "power.ratedKW";
17
+ /** 멈춰 있을 때의 소비(kW). 없으면 멈춘 동안을 **비운다** — 0 이라고 주장하지 않는다. */
18
+ readonly standbyKW: "power.standbyKW";
15
19
  };
16
20
  export declare const EMS_TYPES: TwinTypeInfo[];
@@ -10,7 +10,17 @@ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', '
10
10
  */
11
11
  export const EMS_PROPERTY = {
12
12
  /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
13
- contractKW: 'contract.kW'
13
+ contractKW: 'contract.kW',
14
+ /*
15
+ * ── 부하 계수 — 시뮬레이션이 전기를 만들 수 있게 (2026-08-14) ───────────────
16
+ * 「이 설비가 돌면 몇 kW 인가」는 **현장이 아는 값**이다. 그래서 타입 기본값을 두지 않는다:
17
+ * 선언하지 않은 설비는 부하를 만들지 않는다. 기본값을 두면 아무도 그 수가 짐작인 줄 모른 채
18
+ * 요금 판정이 그 위에 선다.
19
+ */
20
+ /** 가동 중 소비(kW). */
21
+ ratedKW: 'power.ratedKW',
22
+ /** 멈춰 있을 때의 소비(kW). 없으면 멈춘 동안을 **비운다** — 0 이라고 주장하지 않는다. */
23
+ standbyKW: 'power.standbyKW'
14
24
  };
15
25
  export const EMS_TYPES = [
16
26
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -285,6 +285,20 @@ export declare abstract class FlowEngine implements TwinKernel {
285
285
  orders: Map<string, FlowOrder>;
286
286
  revision: number;
287
287
  clockMs: number;
288
+ /**
289
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
290
+ *
291
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
292
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
293
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
294
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
295
+ *
296
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
297
+ * 가짜면 그 구간도 가짜 시각에 선다.
298
+ *
299
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
300
+ */
301
+ protected originMs: number;
288
302
  protected rng: Rng;
289
303
  protected policy: AllocationPolicy;
290
304
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -452,6 +466,15 @@ export declare abstract class FlowEngine implements TwinKernel {
452
466
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
453
467
  */
454
468
  protected nowMs(): number;
469
+ /**
470
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
471
+ *
472
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
473
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
474
+ *
475
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
476
+ */
477
+ setClockOrigin(originMs: number): void;
455
478
  protected now(): string;
456
479
  /**
457
480
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
@@ -227,6 +227,20 @@ export class FlowEngine {
227
227
  orders = new Map();
228
228
  revision = 0;
229
229
  clockMs = 0;
230
+ /**
231
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
232
+ *
233
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
234
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
235
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
236
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
237
+ *
238
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
239
+ * 가짜면 그 구간도 가짜 시각에 선다.
240
+ *
241
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
242
+ */
243
+ originMs = BASE_EPOCH;
230
244
  rng = mulberry32(1);
231
245
  policy;
232
246
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -1016,7 +1030,20 @@ export class FlowEngine {
1016
1030
  */
1017
1031
  nowMs() {
1018
1032
  const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
1019
- return observed ?? BASE_EPOCH + this.clockMs;
1033
+ return observed ?? this.originMs + this.clockMs;
1034
+ }
1035
+ /**
1036
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
1037
+ *
1038
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
1039
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
1040
+ *
1041
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
1042
+ */
1043
+ setClockOrigin(originMs) {
1044
+ if (!Number.isFinite(originMs))
1045
+ throw new Error('clock origin must be a finite epoch millisecond value');
1046
+ this.originMs = originMs;
1020
1047
  }
1021
1048
  now() { return new Date(this.nowMs()).toISOString(); }
1022
1049
  /**
@@ -1606,7 +1606,17 @@ var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
1606
1606
  var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
1607
1607
  var EMS_PROPERTY = {
1608
1608
  /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
1609
- contractKW: "contract.kW"
1609
+ contractKW: "contract.kW",
1610
+ /*
1611
+ * ── 부하 계수 — 시뮬레이션이 전기를 만들 수 있게 (2026-08-14) ───────────────
1612
+ * 「이 설비가 돌면 몇 kW 인가」는 **현장이 아는 값**이다. 그래서 타입 기본값을 두지 않는다:
1613
+ * 선언하지 않은 설비는 부하를 만들지 않는다. 기본값을 두면 아무도 그 수가 짐작인 줄 모른 채
1614
+ * 요금 판정이 그 위에 선다.
1615
+ */
1616
+ /** 가동 중 소비(kW). */
1617
+ ratedKW: "power.ratedKW",
1618
+ /** 멈춰 있을 때의 소비(kW). 없으면 멈춘 동안을 **비운다** — 0 이라고 주장하지 않는다. */
1619
+ standbyKW: "power.standbyKW"
1610
1620
  };
1611
1621
  var EMS_TYPES = [
1612
1622
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -2799,6 +2809,20 @@ var FlowEngine = class {
2799
2809
  orders = /* @__PURE__ */ new Map();
2800
2810
  revision = 0;
2801
2811
  clockMs = 0;
2812
+ /**
2813
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
2814
+ *
2815
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
2816
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
2817
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
2818
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
2819
+ *
2820
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
2821
+ * 가짜면 그 구간도 가짜 시각에 선다.
2822
+ *
2823
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
2824
+ */
2825
+ originMs = BASE_EPOCH;
2802
2826
  rng = mulberry32(1);
2803
2827
  policy;
2804
2828
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -3519,7 +3543,19 @@ var FlowEngine = class {
3519
3543
  */
3520
3544
  nowMs() {
3521
3545
  const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
3522
- return observed ?? BASE_EPOCH + this.clockMs;
3546
+ return observed ?? this.originMs + this.clockMs;
3547
+ }
3548
+ /**
3549
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
3550
+ *
3551
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
3552
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
3553
+ *
3554
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
3555
+ */
3556
+ setClockOrigin(originMs) {
3557
+ if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
3558
+ this.originMs = originMs;
3523
3559
  }
3524
3560
  now() {
3525
3561
  return new Date(this.nowMs()).toISOString();
@@ -5606,6 +5642,9 @@ function windowMsOf(opts) {
5606
5642
  return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
5607
5643
  }
5608
5644
  var KEEP_CLOSED = 96;
5645
+ function isRunningStatus(status) {
5646
+ return status === "busy" || status === "in-use" || status === "running";
5647
+ }
5609
5648
  var EmsKernel = class extends FlowEngine {
5610
5649
  points = /* @__PURE__ */ new Map();
5611
5650
  open;
@@ -5640,6 +5679,21 @@ var EmsKernel = class extends FlowEngine {
5640
5679
  * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
5641
5680
  * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
5642
5681
  */
5682
+ /**
5683
+ * 같은 현장 이웃 트윈의 **설비 가동 상태** — 부하를 만들 때 읽는다.
5684
+ *
5685
+ * ── 왜 커널이 읽나 (2026-08-14) ────────────────────────────────────────────
5686
+ * 시뮬 EMS 트윈은 계측이 없으므로 스스로 전기를 만들어야 한다. 그런데 「무엇이 돌고 있나」는
5687
+ * 공장 트윈이 아는 사실이다. 호스트는 **누가 같은 현장에 있는지**만 알려 주고, 무엇을 읽어
5688
+ * 어떻게 부하로 바꿀지는 커널이 정한다 — 계수와 규칙이 호스트에 흩어지면 트윈마다 다른 답이 된다.
5689
+ *
5690
+ * 여기 실리는 것은 상태뿐이다(kW 가 아니다). kW 는 **이 트윈의 모델이 선언한 계수**로 커널이 만든다.
5691
+ */
5692
+ peerStatus = /* @__PURE__ */ new Map();
5693
+ /** 이웃 설비 상태를 싣는다 — 매 tick 갱신을 전제로 통째로 바꾼다(사라진 설비가 남지 않게). */
5694
+ observePeerEquipment(list) {
5695
+ this.peerStatus = new Map(list.filter((e) => e?.id).map((e) => [String(e.id), String(e.status ?? "")]));
5696
+ }
5643
5697
  declaredContractKW() {
5644
5698
  let max;
5645
5699
  for (const loc of this.boardDef?.locations ?? []) {
@@ -5774,11 +5828,22 @@ var EmsKernel = class extends FlowEngine {
5774
5828
  * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
5775
5829
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
5776
5830
  */
5777
- ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {}
5831
+ ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {},
5832
+ /*
5833
+ * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
5834
+ * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
5835
+ */
5836
+ ...w.derived ? { derived: true } : {}
5778
5837
  });
5779
5838
  if (w.maxKW !== void 0 && (this.peak === void 0 || w.maxKW > this.peak.kW)) {
5780
5839
  this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
5781
- this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {} });
5840
+ this.emitOp(ENERGY_EVENT.peak, {
5841
+ kW: w.maxKW,
5842
+ windowStartMs: w.startMs,
5843
+ ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
5844
+ /* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
5845
+ ...w.derived ? { derived: true } : {}
5846
+ });
5782
5847
  }
5783
5848
  this.open = void 0;
5784
5849
  }
@@ -5845,9 +5910,62 @@ var EmsKernel = class extends FlowEngine {
5845
5910
  this.noteFlowRequest("onTaskComplete");
5846
5911
  }
5847
5912
  /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
5913
+ /**
5914
+ * 선언된 부하 계수 × 가동 상태 → **이 현장의 부하**. 시뮬레이션에서만 만든다.
5915
+ *
5916
+ * ── 규칙 ──────────────────────────────────────────────────────────────────
5917
+ * ① 계수를 선언한 설비만 센다(`power.ratedKW`). 선언이 없으면 그 설비는 부하를 만들지 않는다 —
5918
+ * 타입 기본값을 두지 않기로 했다(계수는 현장이 안다).
5919
+ * ② 가동 여부는 **상태**로 판단한다. 자기 설비에 상태가 있으면 그것을, 없으면 이웃 트윈(같은 현장)의
5920
+ * 상태를 본다. 어느 쪽에도 없으면 **모르는 것**이므로 그 설비는 이번 계산에서 빠진다.
5921
+ * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
5922
+ * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
5923
+ */
5924
+ deriveLoad(atMs) {
5925
+ if (this.observing) return;
5926
+ let total = 0;
5927
+ let counted = 0;
5928
+ for (const e of this.boardDef?.equipment ?? []) {
5929
+ const rated = this.numberProperty(e, EMS_PROPERTY.ratedKW);
5930
+ if (rated === void 0) continue;
5931
+ const status = this.peerStatus.get(e.id) ?? this.equipment.get(e.id)?.status;
5932
+ if (status === void 0 || status === "") continue;
5933
+ if (isRunningStatus(status)) {
5934
+ total += rated;
5935
+ counted++;
5936
+ continue;
5937
+ }
5938
+ const standby = this.numberProperty(e, EMS_PROPERTY.standbyKW);
5939
+ if (standby !== void 0) {
5940
+ total += standby;
5941
+ counted++;
5942
+ }
5943
+ }
5944
+ if (!counted) return;
5945
+ this.closeDue(atMs);
5946
+ this.openWindow(atMs);
5947
+ const w = this.open;
5948
+ w.derived = true;
5949
+ if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
5950
+ w.samples++;
5951
+ w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
5952
+ this.revision++;
5953
+ this.judgeOpenWindow(atMs);
5954
+ }
5955
+ /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
5956
+ numberProperty(resource, id) {
5957
+ for (const p of resource?.properties ?? []) {
5958
+ if (p?.id !== id) continue;
5959
+ const v = Number(p.value);
5960
+ if (Number.isFinite(v) && v >= 0) return v;
5961
+ }
5962
+ return void 0;
5963
+ }
5848
5964
  tick(dtMs) {
5849
5965
  super.tick(dtMs);
5850
- this.closeDue(this.clockMs);
5966
+ const at = this.nowMs();
5967
+ this.deriveLoad(at);
5968
+ this.closeDue(at);
5851
5969
  }
5852
5970
  getSnapshot() {
5853
5971
  const snap = super.getSnapshot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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": {