@operato/twin-kernel 0.7.2 → 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.
- package/dist/capability.d.ts +10 -1
- package/dist/capability.js +17 -1
- package/dist/contract.d.ts +8 -0
- package/dist/ems-kernel.d.ts +30 -0
- package/dist/ems-kernel.js +89 -0
- package/dist/ems-profile.d.ts +4 -0
- package/dist/ems-profile.js +17 -3
- package/dist-cjs/index.cjs +108 -4
- package/package.json +1 -1
package/dist/capability.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type CapabilityKey = 'operable' | 'storable' | 'mobile' | 'processable' | 'trackable' | 'metered' | 'curtailable' | 'generating' | 'storing';
|
|
1
|
+
export type CapabilityKey = 'operable' | 'storable' | 'mobile' | 'processable' | 'trackable' | 'switching' | 'metered' | 'curtailable' | 'generating' | 'storing';
|
|
2
2
|
/** 이동 관측 — Mobile 이 노출. */
|
|
3
3
|
export interface Motion {
|
|
4
4
|
fromNode: string;
|
|
@@ -58,6 +58,15 @@ export interface StoringState {
|
|
|
58
58
|
chargeKW?: number;
|
|
59
59
|
dischargeKW?: number;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* 개폐 위치 — IEC 61850 `XCBR.Pos`·`XSWI.Pos` 의 넷 값을 그대로 쓴다.
|
|
63
|
+
*
|
|
64
|
+
* `intermediate`(과도)와 `bad`(위치 불량)를 **뭉개지 않는다**: 실 계통에서 이 둘은 이중 접점이 서로
|
|
65
|
+
* 다른 말을 하는 상태이고, 「열림/닫힘」으로 반올림하면 트윈이 없는 확신을 갖는다.
|
|
66
|
+
*/
|
|
67
|
+
export interface SwitchingState {
|
|
68
|
+
position?: 'open' | 'closed' | 'intermediate' | 'bad';
|
|
69
|
+
}
|
|
61
70
|
/** 능력 관측 계약 메타(발견·검증). ops 없음(원칙 ①). */
|
|
62
71
|
export interface CapabilitySpec {
|
|
63
72
|
key: CapabilityKey;
|
package/dist/capability.js
CHANGED
|
@@ -33,6 +33,22 @@ export const CAPABILITIES = {
|
|
|
33
33
|
key: 'processable', label: 'twin.capability.processable', semantics: '변환/가공 수행 — 산출(양품/불량). 운영 status 는 Operable 조합. progress 방출은 후속.',
|
|
34
34
|
stateFields: ['output'], results: ['completed']
|
|
35
35
|
},
|
|
36
|
+
/*
|
|
37
|
+
* ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
|
|
38
|
+
* 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
|
|
39
|
+
* (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
|
|
40
|
+
* **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
|
|
41
|
+
*
|
|
42
|
+
* 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
|
|
43
|
+
* `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
|
|
44
|
+
* 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
|
|
45
|
+
*
|
|
46
|
+
* `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
|
|
47
|
+
*/
|
|
48
|
+
switching: {
|
|
49
|
+
key: 'switching', label: 'twin.capability.switching', semantics: '회로를 열고 닫는 기기의 **위치를 읽는다** — 우리는 조작하지 않는다(명령 없음). IEC 61850 XCBR/XSWI Pos.',
|
|
50
|
+
stateFields: ['position'], models: ['SwitchingState'], results: ['positionChanged']
|
|
51
|
+
},
|
|
36
52
|
trackable: {
|
|
37
53
|
key: 'trackable', label: 'twin.capability.trackable', semantics: '오더/아이템 생애 추적 — 생애단계(도메인 라벨, 무방언)·진행·보류.',
|
|
38
54
|
stateFields: ['lifecycle', 'progress', 'held'], results: ['lifecycleChanged']
|
|
@@ -60,7 +76,7 @@ export const CAPABILITIES = {
|
|
|
60
76
|
stateFields: ['soc', 'chargeKW', 'dischargeKW'], models: ['StoringState'], invariants: ['0 <= soc <= 100'], results: ['stored', 'discharged']
|
|
61
77
|
}
|
|
62
78
|
};
|
|
63
|
-
export const CAPABILITY_KEYS = ['operable', 'storable', 'mobile', 'processable', 'trackable', 'metered', 'curtailable', 'generating', 'storing'];
|
|
79
|
+
export const CAPABILITY_KEYS = ['operable', 'storable', 'mobile', 'processable', 'trackable', 'switching', 'metered', 'curtailable', 'generating', 'storing'];
|
|
64
80
|
/** 능력 집합이 관측 노출하는 상태 필드 합집합(직교이므로 단순 병합). */
|
|
65
81
|
export function stateFieldsOf(caps) {
|
|
66
82
|
const out = new Set();
|
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[];
|
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 ?? []) {
|
|
@@ -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() {
|
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
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
@@ -69,11 +79,15 @@ export const EMS_TYPES = [
|
|
|
69
79
|
label: 'twin.type.breaker',
|
|
70
80
|
/*
|
|
71
81
|
* 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
|
|
72
|
-
* 상태를 읽어 계통 구성을 알
|
|
82
|
+
* 상태를 읽어 계통 구성을 알 뿐이다.
|
|
83
|
+
*
|
|
84
|
+
* 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
|
|
85
|
+
* **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
|
|
86
|
+
* 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
|
|
73
87
|
*/
|
|
74
88
|
standardClass: { iec61850: 'XCBR', iso55000: 'Asset' },
|
|
75
89
|
identity: { scheme: 'kernel:id' },
|
|
76
|
-
capabilities: ['
|
|
90
|
+
capabilities: ['switching']
|
|
77
91
|
},
|
|
78
92
|
{
|
|
79
93
|
key: 'pv-array',
|
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
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
@@ -1665,11 +1675,15 @@ var EMS_TYPES = [
|
|
|
1665
1675
|
label: "twin.type.breaker",
|
|
1666
1676
|
/*
|
|
1667
1677
|
* 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
|
|
1668
|
-
* 상태를 읽어 계통 구성을 알
|
|
1678
|
+
* 상태를 읽어 계통 구성을 알 뿐이다.
|
|
1679
|
+
*
|
|
1680
|
+
* 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
|
|
1681
|
+
* **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
|
|
1682
|
+
* 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
|
|
1669
1683
|
*/
|
|
1670
1684
|
standardClass: { iec61850: "XCBR", iso55000: "Asset" },
|
|
1671
1685
|
identity: { scheme: "kernel:id" },
|
|
1672
|
-
capabilities: ["
|
|
1686
|
+
capabilities: ["switching"]
|
|
1673
1687
|
},
|
|
1674
1688
|
{
|
|
1675
1689
|
key: "pv-array",
|
|
@@ -1760,6 +1774,26 @@ var CAPABILITIES = {
|
|
|
1760
1774
|
stateFields: ["output"],
|
|
1761
1775
|
results: ["completed"]
|
|
1762
1776
|
},
|
|
1777
|
+
/*
|
|
1778
|
+
* ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
|
|
1779
|
+
* 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
|
|
1780
|
+
* (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
|
|
1781
|
+
* **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
|
|
1782
|
+
*
|
|
1783
|
+
* 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
|
|
1784
|
+
* `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
|
|
1785
|
+
* 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
|
|
1786
|
+
*
|
|
1787
|
+
* `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
|
|
1788
|
+
*/
|
|
1789
|
+
switching: {
|
|
1790
|
+
key: "switching",
|
|
1791
|
+
label: "twin.capability.switching",
|
|
1792
|
+
semantics: "\uD68C\uB85C\uB97C \uC5F4\uACE0 \uB2EB\uB294 \uAE30\uAE30\uC758 **\uC704\uCE58\uB97C \uC77D\uB294\uB2E4** \u2014 \uC6B0\uB9AC\uB294 \uC870\uC791\uD558\uC9C0 \uC54A\uB294\uB2E4(\uBA85\uB839 \uC5C6\uC74C). IEC 61850 XCBR/XSWI Pos.",
|
|
1793
|
+
stateFields: ["position"],
|
|
1794
|
+
models: ["SwitchingState"],
|
|
1795
|
+
results: ["positionChanged"]
|
|
1796
|
+
},
|
|
1763
1797
|
trackable: {
|
|
1764
1798
|
key: "trackable",
|
|
1765
1799
|
label: "twin.capability.trackable",
|
|
@@ -1808,7 +1842,7 @@ var CAPABILITIES = {
|
|
|
1808
1842
|
results: ["stored", "discharged"]
|
|
1809
1843
|
}
|
|
1810
1844
|
};
|
|
1811
|
-
var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "metered", "curtailable", "generating", "storing"];
|
|
1845
|
+
var CAPABILITY_KEYS = ["operable", "storable", "mobile", "processable", "trackable", "switching", "metered", "curtailable", "generating", "storing"];
|
|
1812
1846
|
function stateFieldsOf(caps) {
|
|
1813
1847
|
const out = /* @__PURE__ */ new Set();
|
|
1814
1848
|
for (const c of caps) for (const f of CAPABILITIES[c]?.stateFields ?? []) out.add(f);
|
|
@@ -5582,6 +5616,9 @@ function windowMsOf(opts) {
|
|
|
5582
5616
|
return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
|
|
5583
5617
|
}
|
|
5584
5618
|
var KEEP_CLOSED = 96;
|
|
5619
|
+
function isRunningStatus(status) {
|
|
5620
|
+
return status === "busy" || status === "in-use" || status === "running";
|
|
5621
|
+
}
|
|
5585
5622
|
var EmsKernel = class extends FlowEngine {
|
|
5586
5623
|
points = /* @__PURE__ */ new Map();
|
|
5587
5624
|
open;
|
|
@@ -5616,6 +5653,21 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5616
5653
|
* 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
|
|
5617
5654
|
* (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
|
|
5618
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
|
+
}
|
|
5619
5671
|
declaredContractKW() {
|
|
5620
5672
|
let max;
|
|
5621
5673
|
for (const loc of this.boardDef?.locations ?? []) {
|
|
@@ -5821,8 +5873,60 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5821
5873
|
this.noteFlowRequest("onTaskComplete");
|
|
5822
5874
|
}
|
|
5823
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
|
+
}
|
|
5824
5927
|
tick(dtMs) {
|
|
5825
5928
|
super.tick(dtMs);
|
|
5929
|
+
this.deriveLoad(this.clockMs);
|
|
5826
5930
|
this.closeDue(this.clockMs);
|
|
5827
5931
|
}
|
|
5828
5932
|
getSnapshot() {
|
package/package.json
CHANGED