@operato/twin-kernel 0.7.22 → 0.7.23
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 +8 -0
- package/dist/ems-kernel.d.ts +33 -1
- package/dist/ems-kernel.js +95 -1
- package/dist/flow-engine.d.ts +1 -0
- package/dist/flow-engine.js +28 -11
- package/dist-cjs/index.cjs +110 -10
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -1329,6 +1329,14 @@ export interface RecommendedAction {
|
|
|
1329
1329
|
code: string;
|
|
1330
1330
|
command?: string;
|
|
1331
1331
|
args?: unknown;
|
|
1332
|
+
/**
|
|
1333
|
+
* 표현용 원시 파라미터 — 권고가 **무엇을 가리키는지** 말해야 할 때(예: 「어느 부하를 줄이나」).
|
|
1334
|
+
*
|
|
1335
|
+
* 실행 가능한 조치는 `args` 가 대상을 나른다(커맨드가 그것을 먹는다). 권고(command 없음)에는 그 자리가
|
|
1336
|
+
* 없어서, 예전에는 「부하를 줄이세요」까지만 말할 수 있었다 — 감축 가능 설비가 여럿이면 사람이 어느
|
|
1337
|
+
* 것인지 알 길이 없다. `Attention.params` 와 같은 규약이다(언어중립 원시값만, 문장은 표현계층이 만든다).
|
|
1338
|
+
*/
|
|
1339
|
+
params?: Record<string, string | number>;
|
|
1332
1340
|
}
|
|
1333
1341
|
export interface StateSnapshot {
|
|
1334
1342
|
revision: number;
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FlowEngine } from './flow-engine.ts';
|
|
2
2
|
import { type AllocationPolicy } from './allocation-policy.ts';
|
|
3
|
-
import { type CanonicalEnvelope, type DemandWindowState, type StateSnapshot } from './contract.ts';
|
|
3
|
+
import { type CanonicalEnvelope, type DemandWindowState, type StateSnapshot, type Attention, type StructureShift, type TwinModelDef } from './contract.ts';
|
|
4
4
|
/** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
|
|
5
5
|
export declare const DEMAND_WINDOW_MS: number;
|
|
6
6
|
/** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
|
|
@@ -63,6 +63,19 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
63
63
|
* 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
|
|
64
64
|
*/
|
|
65
65
|
private rootMeterIds;
|
|
66
|
+
/**
|
|
67
|
+
* 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
|
|
68
|
+
*
|
|
69
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
70
|
+
* 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
|
|
71
|
+
* 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
|
|
72
|
+
* 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
|
|
73
|
+
* 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
|
|
74
|
+
*
|
|
75
|
+
* 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
|
|
76
|
+
* 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
|
|
77
|
+
*/
|
|
78
|
+
adoptStructure(def: TwinModelDef): StructureShift;
|
|
66
79
|
/**
|
|
67
80
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
68
81
|
*
|
|
@@ -119,6 +132,25 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
119
132
|
* 우리는 끄지 않는다.
|
|
120
133
|
*/
|
|
121
134
|
private judgeOpenWindow;
|
|
135
|
+
/**
|
|
136
|
+
* 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
|
|
137
|
+
*
|
|
138
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
139
|
+
* 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
|
|
140
|
+
* 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
|
|
141
|
+
* 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
|
|
142
|
+
*
|
|
143
|
+
* ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
|
|
144
|
+
* 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
|
|
145
|
+
* 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
|
|
146
|
+
* 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
|
|
147
|
+
*
|
|
148
|
+
* 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
|
|
149
|
+
* 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
|
|
150
|
+
*/
|
|
151
|
+
protected collectAttentions(): Attention[];
|
|
152
|
+
/** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
|
|
153
|
+
private incomingLocationId;
|
|
122
154
|
/** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
|
|
123
155
|
private curtailableIds;
|
|
124
156
|
private flowRequests;
|
package/dist/ems-kernel.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { FlowEngine } from "./flow-engine.js";
|
|
29
29
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
30
|
-
import { ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
|
|
30
|
+
import { CMD, ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
|
|
31
31
|
import { EMS_PROPERTY, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
|
|
32
32
|
/** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
|
|
33
33
|
export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
|
|
@@ -170,6 +170,31 @@ export class EmsKernel extends FlowEngine {
|
|
|
170
170
|
}
|
|
171
171
|
return out;
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
|
|
175
|
+
*
|
|
176
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
177
|
+
* 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
|
|
178
|
+
* 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
|
|
179
|
+
* 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
|
|
180
|
+
* 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
|
|
181
|
+
*
|
|
182
|
+
* 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
|
|
183
|
+
* 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
|
|
184
|
+
*/
|
|
185
|
+
adoptStructure(def) {
|
|
186
|
+
const shift = super.adoptStructure(def);
|
|
187
|
+
if (this.open) {
|
|
188
|
+
const contractKW = this.declaredContractKW();
|
|
189
|
+
if (contractKW === undefined)
|
|
190
|
+
delete this.open.contractKW;
|
|
191
|
+
else
|
|
192
|
+
this.open.contractKW = contractKW;
|
|
193
|
+
/* 새 기준으로 다시 판정할 수 있게 이 구간의 「이미 제안했다」 표식을 지운다. */
|
|
194
|
+
this.suggestedFor = undefined;
|
|
195
|
+
}
|
|
196
|
+
return shift;
|
|
197
|
+
}
|
|
173
198
|
/**
|
|
174
199
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
175
200
|
*
|
|
@@ -463,6 +488,75 @@ export class EmsKernel extends FlowEngine {
|
|
|
463
488
|
curtailableCandidates: this.curtailableIds()
|
|
464
489
|
});
|
|
465
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
|
|
493
|
+
*
|
|
494
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
495
|
+
* 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
|
|
496
|
+
* 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
|
|
497
|
+
* 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
|
|
498
|
+
*
|
|
499
|
+
* ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
|
|
500
|
+
* 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
|
|
501
|
+
* 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
|
|
502
|
+
* 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
|
|
503
|
+
*
|
|
504
|
+
* 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
|
|
505
|
+
* 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
|
|
506
|
+
*/
|
|
507
|
+
collectAttentions() {
|
|
508
|
+
const out = super.collectAttentions();
|
|
509
|
+
const w = this.open;
|
|
510
|
+
if (!w || w.contractKW === undefined || w.meanKW === undefined)
|
|
511
|
+
return out;
|
|
512
|
+
/* 투영은 커널이 이미 쓰는 규칙과 같다(`mean-so-far`) — 두 벌이 되면 화면과 저널이 다른 말을 한다. */
|
|
513
|
+
const projected = w.meanKW;
|
|
514
|
+
if (projected <= w.contractKW)
|
|
515
|
+
return out;
|
|
516
|
+
const over = projected - w.contractKW;
|
|
517
|
+
const ratio = over / w.contractKW;
|
|
518
|
+
const candidates = this.curtailableIds();
|
|
519
|
+
out.push({
|
|
520
|
+
/* 구간마다 새 신호다 — 15분이 지나면 다른 요금 단위이고, 확인(ack)도 그 구간에만 걸려야 한다. */
|
|
521
|
+
id: `contract-projected-over:${w.startMs}`,
|
|
522
|
+
kind: 'contract-projected-over',
|
|
523
|
+
severity: ratio > 0.15 ? 'critical' : ratio > 0.05 ? 'high' : 'medium',
|
|
524
|
+
anchor: { locationId: this.incomingLocationId() },
|
|
525
|
+
/* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다(커널은 사람 말을 만들지 않는다). */
|
|
526
|
+
params: {
|
|
527
|
+
projectedKW: Math.round(projected),
|
|
528
|
+
contractKW: w.contractKW,
|
|
529
|
+
gapKW: Math.round(over),
|
|
530
|
+
windowEndMs: w.endMs,
|
|
531
|
+
curtailableCount: candidates.length
|
|
532
|
+
},
|
|
533
|
+
/*
|
|
534
|
+
* 조치는 **선언된 감축 가능 설비**에만 건다 — 커널이 무엇을 끌 수 있는지 짐작하지 않는다.
|
|
535
|
+
*
|
|
536
|
+
* ── 구동 방식이 조치의 성질을 바꾼다 ─────────────────────────────────────
|
|
537
|
+
* 시뮬레이션에서 감축은 **실행할 수 있는 조치**다: 설비를 세우면 그 설비의 부하가 실제로 빠지고
|
|
538
|
+
* 투영이 내려간다(what-if 의 값이 여기서 나온다). 미러에서는 그렇지 않다 — 진실은 계량기가 정하고
|
|
539
|
+
* 이 커널은 **제어하지 않는다**(설계 §1). 트윈 안에서 설비를 세워도 현장 전기는 그대로 흐르므로,
|
|
540
|
+
* 실행 버튼을 주면 화면이 「눌렀으니 내려갈 것」이라고 거짓말한다. 그래서 미러에서는 **권고**로만
|
|
541
|
+
* 낸다(command 없이 code 만 — 표현계층이 advice 로 렌더한다).
|
|
542
|
+
*/
|
|
543
|
+
recommendedActions: candidates.map(id => this.observing
|
|
544
|
+
? { code: 'advice.curtail-load', params: { resourceId: id } }
|
|
545
|
+
: { code: 'act.curtail-load', command: CMD.resourceHold, args: { resourceId: id } }),
|
|
546
|
+
...(candidates.length && !this.observing
|
|
547
|
+
? { suggestedAction: { code: 'act.curtail-load', command: CMD.resourceHold, args: { resourceId: candidates[0] } } }
|
|
548
|
+
: {})
|
|
549
|
+
});
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
/** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
|
|
553
|
+
incomingLocationId() {
|
|
554
|
+
for (const loc of this.boardDef?.locations ?? []) {
|
|
555
|
+
if ((loc.properties ?? []).some(p => p.id === EMS_PROPERTY.contractKW))
|
|
556
|
+
return loc.id;
|
|
557
|
+
}
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
466
560
|
/** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
|
|
467
561
|
curtailableIds() {
|
|
468
562
|
return (this.boardDef?.equipment ?? [])
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -462,6 +462,7 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
462
462
|
tick(dtMs: number): void;
|
|
463
463
|
getSnapshot(): StateSnapshot;
|
|
464
464
|
protected computeAttentions(): Attention[];
|
|
465
|
+
protected collectAttentions(): Attention[];
|
|
465
466
|
/**
|
|
466
467
|
* fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
|
|
467
468
|
* 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
|
package/dist/flow-engine.js
CHANGED
|
@@ -1026,20 +1026,16 @@ export class FlowEngine {
|
|
|
1026
1026
|
};
|
|
1027
1027
|
}
|
|
1028
1028
|
/*
|
|
1029
|
-
* 주목 신호
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1029
|
+
* 주목 신호 — **수명 관리는 여기 한 곳**이다(확장하지 말 것, 확장점은 `collectAttentions`).
|
|
1030
|
+
*
|
|
1031
|
+
* 확인(ack)·처음 성립한 시각·사라진 조건 프루닝은 신호를 **누가 냈든** 같아야 한다. 예전에는 판단과
|
|
1032
|
+
* 수명이 이 메서드 하나에 섞여 있어서, 서브클래스가 `super.computeAttentions()` 뒤에 신호를 덧붙이면
|
|
1033
|
+
* 그 신호만 **timeStamp 도 ack 도 못 받았다**(화면은 「몇 분째」를 쓸 수 없고, 확인 버튼이 듣지 않는다).
|
|
1034
|
+
* 그래서 판단(`collectAttentions`)과 수명(여기)을 갈랐다.
|
|
1032
1035
|
*/
|
|
1033
1036
|
computeAttentions() {
|
|
1034
|
-
|
|
1037
|
+
const out = this.collectAttentions();
|
|
1035
1038
|
const now = this.now();
|
|
1036
|
-
const out = deriveAttentions({
|
|
1037
|
-
equipment: [...this.equipment.values()],
|
|
1038
|
-
locations: [...this.locations.values()],
|
|
1039
|
-
orders: [...this.orders.values()],
|
|
1040
|
-
tasks: [...this.tasks.values()]
|
|
1041
|
-
}, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
|
|
1042
|
-
);
|
|
1043
1039
|
const present = new Set(out.map(a => a.id));
|
|
1044
1040
|
/*
|
|
1045
1041
|
* **조건이 처음 성립한 시각**을 기억해 붙인다(표준 `WorkAlert.TimeStamp`).
|
|
@@ -1052,6 +1048,9 @@ export class FlowEngine {
|
|
|
1052
1048
|
const first = this._attentionSince.get(a.id) ?? now;
|
|
1053
1049
|
this._attentionSince.set(a.id, first);
|
|
1054
1050
|
a.timeStamp = first;
|
|
1051
|
+
/* 확인 상태도 신호를 낸 주체와 무관하게 적용한다 — 순수 함수가 낸 것만 확인되면 반쪽이다. */
|
|
1052
|
+
if (this._acked.has(a.id))
|
|
1053
|
+
a.state = 'acknowledged';
|
|
1055
1054
|
}
|
|
1056
1055
|
// 확인(ack)·시작 시각 프루닝 — 사라진 조건은 함께 지운다(재발하면 그때가 새 시작이다).
|
|
1057
1056
|
for (const id of [...this._acked])
|
|
@@ -1062,6 +1061,24 @@ export class FlowEngine {
|
|
|
1062
1061
|
this._attentionSince.delete(id);
|
|
1063
1062
|
return out;
|
|
1064
1063
|
}
|
|
1064
|
+
/*
|
|
1065
|
+
* 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
|
|
1066
|
+
* severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
|
|
1067
|
+
* **도메인별 추가 판단은 이것을 override 로 확장한다**(`super.collectAttentions()` 합성) — 수명은
|
|
1068
|
+
* 상위 `computeAttentions` 가 붙여 준다.
|
|
1069
|
+
*/
|
|
1070
|
+
collectAttentions() {
|
|
1071
|
+
// 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
|
|
1072
|
+
const now = this.now();
|
|
1073
|
+
const out = deriveAttentions({
|
|
1074
|
+
equipment: [...this.equipment.values()],
|
|
1075
|
+
locations: [...this.locations.values()],
|
|
1076
|
+
orders: [...this.orders.values()],
|
|
1077
|
+
tasks: [...this.tasks.values()]
|
|
1078
|
+
}, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
|
|
1079
|
+
);
|
|
1080
|
+
return out;
|
|
1081
|
+
}
|
|
1065
1082
|
/**
|
|
1066
1083
|
* fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
|
|
1067
1084
|
* 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -3761,12 +3761,35 @@ var FlowEngine = class {
|
|
|
3761
3761
|
acked: [...this._acked]
|
|
3762
3762
|
};
|
|
3763
3763
|
}
|
|
3764
|
+
/*
|
|
3765
|
+
* 주목 신호 — **수명 관리는 여기 한 곳**이다(확장하지 말 것, 확장점은 `collectAttentions`).
|
|
3766
|
+
*
|
|
3767
|
+
* 확인(ack)·처음 성립한 시각·사라진 조건 프루닝은 신호를 **누가 냈든** 같아야 한다. 예전에는 판단과
|
|
3768
|
+
* 수명이 이 메서드 하나에 섞여 있어서, 서브클래스가 `super.computeAttentions()` 뒤에 신호를 덧붙이면
|
|
3769
|
+
* 그 신호만 **timeStamp 도 ack 도 못 받았다**(화면은 「몇 분째」를 쓸 수 없고, 확인 버튼이 듣지 않는다).
|
|
3770
|
+
* 그래서 판단(`collectAttentions`)과 수명(여기)을 갈랐다.
|
|
3771
|
+
*/
|
|
3772
|
+
computeAttentions() {
|
|
3773
|
+
const out = this.collectAttentions();
|
|
3774
|
+
const now = this.now();
|
|
3775
|
+
const present = new Set(out.map((a) => a.id));
|
|
3776
|
+
for (const a of out) {
|
|
3777
|
+
const first = this._attentionSince.get(a.id) ?? now;
|
|
3778
|
+
this._attentionSince.set(a.id, first);
|
|
3779
|
+
a.timeStamp = first;
|
|
3780
|
+
if (this._acked.has(a.id)) a.state = "acknowledged";
|
|
3781
|
+
}
|
|
3782
|
+
for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
|
|
3783
|
+
for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
|
|
3784
|
+
return out;
|
|
3785
|
+
}
|
|
3764
3786
|
/*
|
|
3765
3787
|
* 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
|
|
3766
3788
|
* severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
|
|
3767
|
-
*
|
|
3789
|
+
* **도메인별 추가 판단은 이것을 override 로 확장한다**(`super.collectAttentions()` 합성) — 수명은
|
|
3790
|
+
* 상위 `computeAttentions` 가 붙여 준다.
|
|
3768
3791
|
*/
|
|
3769
|
-
|
|
3792
|
+
collectAttentions() {
|
|
3770
3793
|
const now = this.now();
|
|
3771
3794
|
const out = deriveAttentions(
|
|
3772
3795
|
{
|
|
@@ -3779,14 +3802,6 @@ var FlowEngine = class {
|
|
|
3779
3802
|
now
|
|
3780
3803
|
// 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
|
|
3781
3804
|
);
|
|
3782
|
-
const present = new Set(out.map((a) => a.id));
|
|
3783
|
-
for (const a of out) {
|
|
3784
|
-
const first = this._attentionSince.get(a.id) ?? now;
|
|
3785
|
-
this._attentionSince.set(a.id, first);
|
|
3786
|
-
a.timeStamp = first;
|
|
3787
|
-
}
|
|
3788
|
-
for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
|
|
3789
|
-
for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
|
|
3790
3805
|
return out;
|
|
3791
3806
|
}
|
|
3792
3807
|
/**
|
|
@@ -6129,6 +6144,28 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6129
6144
|
}
|
|
6130
6145
|
return out;
|
|
6131
6146
|
}
|
|
6147
|
+
/**
|
|
6148
|
+
* 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
|
|
6149
|
+
*
|
|
6150
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
6151
|
+
* 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
|
|
6152
|
+
* 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
|
|
6153
|
+
* 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
|
|
6154
|
+
* 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
|
|
6155
|
+
*
|
|
6156
|
+
* 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
|
|
6157
|
+
* 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
|
|
6158
|
+
*/
|
|
6159
|
+
adoptStructure(def) {
|
|
6160
|
+
const shift = super.adoptStructure(def);
|
|
6161
|
+
if (this.open) {
|
|
6162
|
+
const contractKW = this.declaredContractKW();
|
|
6163
|
+
if (contractKW === void 0) delete this.open.contractKW;
|
|
6164
|
+
else this.open.contractKW = contractKW;
|
|
6165
|
+
this.suggestedFor = void 0;
|
|
6166
|
+
}
|
|
6167
|
+
return shift;
|
|
6168
|
+
}
|
|
6132
6169
|
/**
|
|
6133
6170
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
6134
6171
|
*
|
|
@@ -6355,6 +6392,69 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6355
6392
|
curtailableCandidates: this.curtailableIds()
|
|
6356
6393
|
});
|
|
6357
6394
|
}
|
|
6395
|
+
/**
|
|
6396
|
+
* 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
|
|
6397
|
+
*
|
|
6398
|
+
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
|
|
6399
|
+
* 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
|
|
6400
|
+
* 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
|
|
6401
|
+
* 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
|
|
6402
|
+
*
|
|
6403
|
+
* ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
|
|
6404
|
+
* 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
|
|
6405
|
+
* 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
|
|
6406
|
+
* 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
|
|
6407
|
+
*
|
|
6408
|
+
* 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
|
|
6409
|
+
* 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
|
|
6410
|
+
*/
|
|
6411
|
+
collectAttentions() {
|
|
6412
|
+
const out = super.collectAttentions();
|
|
6413
|
+
const w = this.open;
|
|
6414
|
+
if (!w || w.contractKW === void 0 || w.meanKW === void 0) return out;
|
|
6415
|
+
const projected = w.meanKW;
|
|
6416
|
+
if (projected <= w.contractKW) return out;
|
|
6417
|
+
const over = projected - w.contractKW;
|
|
6418
|
+
const ratio = over / w.contractKW;
|
|
6419
|
+
const candidates = this.curtailableIds();
|
|
6420
|
+
out.push({
|
|
6421
|
+
/* 구간마다 새 신호다 — 15분이 지나면 다른 요금 단위이고, 확인(ack)도 그 구간에만 걸려야 한다. */
|
|
6422
|
+
id: `contract-projected-over:${w.startMs}`,
|
|
6423
|
+
kind: "contract-projected-over",
|
|
6424
|
+
severity: ratio > 0.15 ? "critical" : ratio > 0.05 ? "high" : "medium",
|
|
6425
|
+
anchor: { locationId: this.incomingLocationId() },
|
|
6426
|
+
/* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다(커널은 사람 말을 만들지 않는다). */
|
|
6427
|
+
params: {
|
|
6428
|
+
projectedKW: Math.round(projected),
|
|
6429
|
+
contractKW: w.contractKW,
|
|
6430
|
+
gapKW: Math.round(over),
|
|
6431
|
+
windowEndMs: w.endMs,
|
|
6432
|
+
curtailableCount: candidates.length
|
|
6433
|
+
},
|
|
6434
|
+
/*
|
|
6435
|
+
* 조치는 **선언된 감축 가능 설비**에만 건다 — 커널이 무엇을 끌 수 있는지 짐작하지 않는다.
|
|
6436
|
+
*
|
|
6437
|
+
* ── 구동 방식이 조치의 성질을 바꾼다 ─────────────────────────────────────
|
|
6438
|
+
* 시뮬레이션에서 감축은 **실행할 수 있는 조치**다: 설비를 세우면 그 설비의 부하가 실제로 빠지고
|
|
6439
|
+
* 투영이 내려간다(what-if 의 값이 여기서 나온다). 미러에서는 그렇지 않다 — 진실은 계량기가 정하고
|
|
6440
|
+
* 이 커널은 **제어하지 않는다**(설계 §1). 트윈 안에서 설비를 세워도 현장 전기는 그대로 흐르므로,
|
|
6441
|
+
* 실행 버튼을 주면 화면이 「눌렀으니 내려갈 것」이라고 거짓말한다. 그래서 미러에서는 **권고**로만
|
|
6442
|
+
* 낸다(command 없이 code 만 — 표현계층이 advice 로 렌더한다).
|
|
6443
|
+
*/
|
|
6444
|
+
recommendedActions: candidates.map(
|
|
6445
|
+
(id) => this.observing ? { code: "advice.curtail-load", params: { resourceId: id } } : { code: "act.curtail-load", command: CMD.resourceHold, args: { resourceId: id } }
|
|
6446
|
+
),
|
|
6447
|
+
...candidates.length && !this.observing ? { suggestedAction: { code: "act.curtail-load", command: CMD.resourceHold, args: { resourceId: candidates[0] } } } : {}
|
|
6448
|
+
});
|
|
6449
|
+
return out;
|
|
6450
|
+
}
|
|
6451
|
+
/** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
|
|
6452
|
+
incomingLocationId() {
|
|
6453
|
+
for (const loc of this.boardDef?.locations ?? []) {
|
|
6454
|
+
if ((loc.properties ?? []).some((p) => p.id === EMS_PROPERTY.contractKW)) return loc.id;
|
|
6455
|
+
}
|
|
6456
|
+
return void 0;
|
|
6457
|
+
}
|
|
6358
6458
|
/** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
|
|
6359
6459
|
curtailableIds() {
|
|
6360
6460
|
return (this.boardDef?.equipment ?? []).filter((e) => String(e.kind ?? "") === "curtailable-load").map((e) => e.id);
|
package/package.json
CHANGED