@operato/twin-kernel 0.7.3 → 0.7.4

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[];
@@ -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 ?? []) {
@@ -330,8 +354,73 @@ export class EmsKernel extends FlowEngine {
330
354
  this.noteFlowRequest('onTaskComplete');
331
355
  }
332
356
  /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
357
+ /**
358
+ * 선언된 부하 계수 × 가동 상태 → **이 현장의 부하**. 시뮬레이션에서만 만든다.
359
+ *
360
+ * ── 규칙 ──────────────────────────────────────────────────────────────────
361
+ * ① 계수를 선언한 설비만 센다(`power.ratedKW`). 선언이 없으면 그 설비는 부하를 만들지 않는다 —
362
+ * 타입 기본값을 두지 않기로 했다(계수는 현장이 안다).
363
+ * ② 가동 여부는 **상태**로 판단한다. 자기 설비에 상태가 있으면 그것을, 없으면 이웃 트윈(같은 현장)의
364
+ * 상태를 본다. 어느 쪽에도 없으면 **모르는 것**이므로 그 설비는 이번 계산에서 빠진다.
365
+ * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
366
+ * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
367
+ */
368
+ deriveLoad(atMs) {
369
+ if (this.observing)
370
+ return; // 미러 — 계측이 진실이다
371
+ let total = 0;
372
+ let counted = 0;
373
+ for (const e of this.boardDef?.equipment ?? []) {
374
+ const rated = this.numberProperty(e, EMS_PROPERTY.ratedKW);
375
+ if (rated === undefined)
376
+ continue;
377
+ /*
378
+ * **이웃이 말한 상태가 먼저다.** 설비를 실제로 돌리는 것은 공정 트윈이고, 에너지 트윈의 자기
379
+ * 설비 상태는 모델을 실을 때의 씨앗값(`idle`)일 뿐이다. 자기 것을 먼저 보면 그 씨앗값이 이웃의
380
+ * 사실을 덮어, 공장이 도는데도 대기전력만 세는 일이 벌어진다(실제로 그렇게 났다).
381
+ */
382
+ const status = this.peerStatus.get(e.id) ?? this.equipment.get(e.id)?.status;
383
+ if (status === undefined || status === '')
384
+ continue; // 모르는 상태 — 지어내지 않는다
385
+ if (isRunningStatus(status)) {
386
+ total += rated;
387
+ counted++;
388
+ continue;
389
+ }
390
+ const standby = this.numberProperty(e, EMS_PROPERTY.standbyKW);
391
+ if (standby !== undefined) {
392
+ total += standby;
393
+ counted++;
394
+ }
395
+ }
396
+ if (!counted)
397
+ return; // 셀 것이 하나도 없으면 구간에 값을 넣지 않는다(0 을 주장하지 않는다)
398
+ this.closeDue(atMs);
399
+ this.openWindow(atMs);
400
+ const w = this.open;
401
+ w.derived = true;
402
+ if (w.maxKW === undefined || total > w.maxKW)
403
+ w.maxKW = total;
404
+ w.samples++;
405
+ w.meanKW = w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
406
+ this.revision++;
407
+ this.judgeOpenWindow(atMs);
408
+ }
409
+ /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
410
+ numberProperty(resource, id) {
411
+ for (const p of resource?.properties ?? []) {
412
+ if (p?.id !== id)
413
+ continue;
414
+ const v = Number(p.value);
415
+ if (Number.isFinite(v) && v >= 0)
416
+ return v;
417
+ }
418
+ return undefined;
419
+ }
333
420
  tick(dtMs) {
334
421
  super.tick(dtMs);
422
+ /* 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다. */
423
+ this.deriveLoad(this.clockMs);
335
424
  this.closeDue(this.clockMs);
336
425
  }
337
426
  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
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -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
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -5606,6 +5616,9 @@ function windowMsOf(opts) {
5606
5616
  return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
5607
5617
  }
5608
5618
  var KEEP_CLOSED = 96;
5619
+ function isRunningStatus(status) {
5620
+ return status === "busy" || status === "in-use" || status === "running";
5621
+ }
5609
5622
  var EmsKernel = class extends FlowEngine {
5610
5623
  points = /* @__PURE__ */ new Map();
5611
5624
  open;
@@ -5640,6 +5653,21 @@ var EmsKernel = class extends FlowEngine {
5640
5653
  * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
5641
5654
  * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
5642
5655
  */
5656
+ /**
5657
+ * 같은 현장 이웃 트윈의 **설비 가동 상태** — 부하를 만들 때 읽는다.
5658
+ *
5659
+ * ── 왜 커널이 읽나 (2026-08-14) ────────────────────────────────────────────
5660
+ * 시뮬 EMS 트윈은 계측이 없으므로 스스로 전기를 만들어야 한다. 그런데 「무엇이 돌고 있나」는
5661
+ * 공장 트윈이 아는 사실이다. 호스트는 **누가 같은 현장에 있는지**만 알려 주고, 무엇을 읽어
5662
+ * 어떻게 부하로 바꿀지는 커널이 정한다 — 계수와 규칙이 호스트에 흩어지면 트윈마다 다른 답이 된다.
5663
+ *
5664
+ * 여기 실리는 것은 상태뿐이다(kW 가 아니다). kW 는 **이 트윈의 모델이 선언한 계수**로 커널이 만든다.
5665
+ */
5666
+ peerStatus = /* @__PURE__ */ new Map();
5667
+ /** 이웃 설비 상태를 싣는다 — 매 tick 갱신을 전제로 통째로 바꾼다(사라진 설비가 남지 않게). */
5668
+ observePeerEquipment(list) {
5669
+ this.peerStatus = new Map(list.filter((e) => e?.id).map((e) => [String(e.id), String(e.status ?? "")]));
5670
+ }
5643
5671
  declaredContractKW() {
5644
5672
  let max;
5645
5673
  for (const loc of this.boardDef?.locations ?? []) {
@@ -5845,8 +5873,60 @@ var EmsKernel = class extends FlowEngine {
5845
5873
  this.noteFlowRequest("onTaskComplete");
5846
5874
  }
5847
5875
  /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
5876
+ /**
5877
+ * 선언된 부하 계수 × 가동 상태 → **이 현장의 부하**. 시뮬레이션에서만 만든다.
5878
+ *
5879
+ * ── 규칙 ──────────────────────────────────────────────────────────────────
5880
+ * ① 계수를 선언한 설비만 센다(`power.ratedKW`). 선언이 없으면 그 설비는 부하를 만들지 않는다 —
5881
+ * 타입 기본값을 두지 않기로 했다(계수는 현장이 안다).
5882
+ * ② 가동 여부는 **상태**로 판단한다. 자기 설비에 상태가 있으면 그것을, 없으면 이웃 트윈(같은 현장)의
5883
+ * 상태를 본다. 어느 쪽에도 없으면 **모르는 것**이므로 그 설비는 이번 계산에서 빠진다.
5884
+ * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
5885
+ * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
5886
+ */
5887
+ deriveLoad(atMs) {
5888
+ if (this.observing) return;
5889
+ let total = 0;
5890
+ let counted = 0;
5891
+ for (const e of this.boardDef?.equipment ?? []) {
5892
+ const rated = this.numberProperty(e, EMS_PROPERTY.ratedKW);
5893
+ if (rated === void 0) continue;
5894
+ const status = this.peerStatus.get(e.id) ?? this.equipment.get(e.id)?.status;
5895
+ if (status === void 0 || status === "") continue;
5896
+ if (isRunningStatus(status)) {
5897
+ total += rated;
5898
+ counted++;
5899
+ continue;
5900
+ }
5901
+ const standby = this.numberProperty(e, EMS_PROPERTY.standbyKW);
5902
+ if (standby !== void 0) {
5903
+ total += standby;
5904
+ counted++;
5905
+ }
5906
+ }
5907
+ if (!counted) return;
5908
+ this.closeDue(atMs);
5909
+ this.openWindow(atMs);
5910
+ const w = this.open;
5911
+ w.derived = true;
5912
+ if (w.maxKW === void 0 || total > w.maxKW) w.maxKW = total;
5913
+ w.samples++;
5914
+ w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
5915
+ this.revision++;
5916
+ this.judgeOpenWindow(atMs);
5917
+ }
5918
+ /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
5919
+ numberProperty(resource, id) {
5920
+ for (const p of resource?.properties ?? []) {
5921
+ if (p?.id !== id) continue;
5922
+ const v = Number(p.value);
5923
+ if (Number.isFinite(v) && v >= 0) return v;
5924
+ }
5925
+ return void 0;
5926
+ }
5848
5927
  tick(dtMs) {
5849
5928
  super.tick(dtMs);
5929
+ this.deriveLoad(this.clockMs);
5850
5930
  this.closeDue(this.clockMs);
5851
5931
  }
5852
5932
  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.4",
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": {