@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.
- package/dist/contract.d.ts +16 -0
- package/dist/ems-kernel.d.ts +30 -0
- package/dist/ems-kernel.js +112 -3
- package/dist/ems-profile.d.ts +4 -0
- package/dist/ems-profile.js +11 -1
- package/dist/flow-engine.d.ts +23 -0
- package/dist/flow-engine.js +28 -1
- package/dist-cjs/index.cjs +123 -5
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -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;
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/ems-kernel.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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();
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -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[];
|
package/dist/ems-profile.js
CHANGED
|
@@ -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
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -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`.
|
package/dist/flow-engine.js
CHANGED
|
@@ -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 ??
|
|
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
|
/**
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -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 ??
|
|
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, {
|
|
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.
|
|
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