@operato/twin-kernel 0.7.21 → 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 +45 -1
- package/dist/ems-kernel.js +152 -2
- package/dist/ems-profile.d.ts +15 -0
- package/dist/ems-profile.js +49 -2
- package/dist/flow-engine.d.ts +1 -0
- package/dist/flow-engine.js +28 -11
- package/dist-cjs/index.cjs +191 -12
- 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;
|
|
@@ -140,6 +172,16 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
140
172
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
141
173
|
*/
|
|
142
174
|
private deriveLoad;
|
|
175
|
+
/**
|
|
176
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
177
|
+
*
|
|
178
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
179
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
180
|
+
*
|
|
181
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
182
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
183
|
+
*/
|
|
184
|
+
private deriveGeneration;
|
|
143
185
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
144
186
|
private hasDispatchPolicy;
|
|
145
187
|
/**
|
|
@@ -163,6 +205,8 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
163
205
|
private emitEnergyEquipment;
|
|
164
206
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
165
207
|
private numberProperty;
|
|
208
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
209
|
+
private stringProperty;
|
|
166
210
|
tick(dtMs: number): void;
|
|
167
211
|
getSnapshot(): StateSnapshot;
|
|
168
212
|
}
|
package/dist/ems-kernel.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { FlowEngine } from "./flow-engine.js";
|
|
29
29
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
30
|
-
import { ENERGY_EVENT } from "./contract.js";
|
|
31
|
-
import { EMS_PROPERTY, electricalUpstreamOf } from "./ems-profile.js";
|
|
30
|
+
import { CMD, ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
|
|
31
|
+
import { EMS_PROPERTY, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
|
|
32
32
|
/** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
|
|
33
33
|
export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
|
|
34
34
|
/** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
|
|
@@ -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 ?? [])
|
|
@@ -555,6 +649,14 @@ export class EmsKernel extends FlowEngine {
|
|
|
555
649
|
* 음수는 **역송**이지 음의 수요가 아니다. 계통 수요는 0 에서 멈추고, 남는 발전은 그 설비의
|
|
556
650
|
* `exportKW` 가 이미 사실로 말한다(여기서 다시 만들지 않는다).
|
|
557
651
|
*/
|
|
652
|
+
/*
|
|
653
|
+
* ── 선언된 발전을 먼저 만든다 (2026-08-18) ─────────────────────────────────
|
|
654
|
+
* 정격과 하루 형상을 선언한 설비는 이 시각의 비율만큼 발전한다. 형상을 우리가 지어내지 않으므로
|
|
655
|
+
* (위도·날씨로 곡선을 만들면 그 수의 출처를 설명할 수 없다) 선언이 없으면 발전하지 않는다.
|
|
656
|
+
*
|
|
657
|
+
* 미러에서는 여기까지 오지 않는다(위에서 관측이 진실이라 돌아간다) — 계측이 발전량을 말해 준다.
|
|
658
|
+
*/
|
|
659
|
+
this.deriveGeneration(atMs);
|
|
558
660
|
let generated = 0;
|
|
559
661
|
let discharged = 0;
|
|
560
662
|
let charged = 0;
|
|
@@ -604,6 +706,43 @@ export class EmsKernel extends FlowEngine {
|
|
|
604
706
|
this.revision++;
|
|
605
707
|
this.judgeOpenWindow(atMs);
|
|
606
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
711
|
+
*
|
|
712
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
713
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
714
|
+
*
|
|
715
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
716
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
717
|
+
*/
|
|
718
|
+
deriveGeneration(atMs) {
|
|
719
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
720
|
+
const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
|
|
721
|
+
if (rated === undefined || !(rated > 0))
|
|
722
|
+
continue;
|
|
723
|
+
const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
|
|
724
|
+
const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
|
|
725
|
+
const fraction = generationFractionAt(profile, hour);
|
|
726
|
+
const state = this.equipment.get(e.id);
|
|
727
|
+
if (!state)
|
|
728
|
+
continue;
|
|
729
|
+
if (fraction === undefined) {
|
|
730
|
+
/* 형상이 없거나 형태가 아니면 발전하지 않는다 — 낡은 값을 남기지 않는다(지난 시각을 말하게 된다). */
|
|
731
|
+
if (Number(state.generatedKW) > 0) {
|
|
732
|
+
;
|
|
733
|
+
state.generatedKW = 0;
|
|
734
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
|
|
735
|
+
}
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const kW = rated * fraction;
|
|
739
|
+
const before = Number(state.generatedKW);
|
|
740
|
+
state.generatedKW = kW;
|
|
741
|
+
/* 값이 실제로 바뀔 때만 사실을 낸다 — 매 틱 같은 값을 되풀면 저널이 소음으로 찬다. */
|
|
742
|
+
if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9))
|
|
743
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
607
746
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
608
747
|
hasDispatchPolicy(equipmentId) {
|
|
609
748
|
for (const e of this.boardDef?.equipment ?? []) {
|
|
@@ -709,6 +848,17 @@ export class EmsKernel extends FlowEngine {
|
|
|
709
848
|
}
|
|
710
849
|
return undefined;
|
|
711
850
|
}
|
|
851
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
852
|
+
stringProperty(resource, id) {
|
|
853
|
+
for (const p of resource?.properties ?? []) {
|
|
854
|
+
if (p?.id !== id)
|
|
855
|
+
continue;
|
|
856
|
+
const v = String(p.value ?? '').trim();
|
|
857
|
+
if (v)
|
|
858
|
+
return v;
|
|
859
|
+
}
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
712
862
|
tick(dtMs) {
|
|
713
863
|
super.tick(dtMs);
|
|
714
864
|
/*
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export declare const EMS_PROPERTY: {
|
|
|
40
40
|
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
|
|
41
41
|
*/
|
|
42
42
|
readonly initialSoc: "storage.initialSoc";
|
|
43
|
+
/** 발전 정격(kW) — 맑은 정오의 최대 출력. */
|
|
44
|
+
readonly genRatedKW: "generation.ratedKW";
|
|
45
|
+
/**
|
|
46
|
+
* 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
|
|
47
|
+
*
|
|
48
|
+
* 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
|
|
49
|
+
*/
|
|
50
|
+
readonly genDailyProfile: "generation.dailyProfile";
|
|
43
51
|
};
|
|
44
52
|
/**
|
|
45
53
|
* 이 선언들의 **단위와 범위** — 값을 읽는 쪽이 아니라 **주는 쪽**을 위한 표다.
|
|
@@ -66,6 +74,13 @@ export interface EmsPropertySpec {
|
|
|
66
74
|
note: string;
|
|
67
75
|
}
|
|
68
76
|
export declare const EMS_PROPERTY_SPEC: Record<string, EmsPropertySpec>;
|
|
77
|
+
/**
|
|
78
|
+
* 하루 형상에서 **이 시각의 비율**을 읽는다 — 없거나 형태가 아니면 `undefined`(발전하지 않는다).
|
|
79
|
+
*
|
|
80
|
+
* 24개가 아니면 받지 않는다: 값이 몇 개인지 짐작해 늘리거나 자르면, 사람이 적은 곡선과 우리가 쓰는 곡선이
|
|
81
|
+
* 달라진다. 0~1 밖의 값도 받지 않는다(정격의 배수로 발전하는 태양광은 없다).
|
|
82
|
+
*/
|
|
83
|
+
export declare function generationFractionAt(profile: string | undefined | null, hourOfDay: number): number | undefined;
|
|
69
84
|
export declare const EMS_TYPES: TwinTypeInfo[];
|
|
70
85
|
/**
|
|
71
86
|
* 이 자리의 **전기 상류** — 어디서 전기를 받는가.
|
package/dist/ems-profile.js
CHANGED
|
@@ -71,7 +71,28 @@ export const EMS_PROPERTY = {
|
|
|
71
71
|
*
|
|
72
72
|
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
|
|
73
73
|
*/
|
|
74
|
-
initialSoc: 'storage.initialSoc'
|
|
74
|
+
initialSoc: 'storage.initialSoc',
|
|
75
|
+
/*
|
|
76
|
+
* ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
|
|
77
|
+
*
|
|
78
|
+
* 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
|
|
79
|
+
* 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
|
|
80
|
+
* 없었다(what-if 의 값이 절반만 성립했다).
|
|
81
|
+
*
|
|
82
|
+
* **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
|
|
83
|
+
* 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
|
|
84
|
+
* (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
|
|
85
|
+
*
|
|
86
|
+
* 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
|
|
87
|
+
*/
|
|
88
|
+
/** 발전 정격(kW) — 맑은 정오의 최대 출력. */
|
|
89
|
+
genRatedKW: 'generation.ratedKW',
|
|
90
|
+
/**
|
|
91
|
+
* 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
|
|
92
|
+
*
|
|
93
|
+
* 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
|
|
94
|
+
*/
|
|
95
|
+
genDailyProfile: 'generation.dailyProfile'
|
|
75
96
|
};
|
|
76
97
|
export const EMS_PROPERTY_SPEC = {
|
|
77
98
|
[EMS_PROPERTY.contractKW]: { uom: 'kW', dataType: 'xs:double', note: 'contracted power at the metering point, in kW.' },
|
|
@@ -85,8 +106,34 @@ export const EMS_PROPERTY_SPEC = {
|
|
|
85
106
|
[EMS_PROPERTY.maxDischargeKW]: { uom: 'kW', dataType: 'xs:double', note: 'inverter limit on discharge rate, in kW.' },
|
|
86
107
|
/* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
|
|
87
108
|
[EMS_PROPERTY.reserveSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below.' },
|
|
88
|
-
[EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' }
|
|
109
|
+
[EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' },
|
|
110
|
+
[EMS_PROPERTY.genRatedKW]: { uom: 'kW', dataType: 'xs:double', note: 'rated generation output at clear-sky noon, in kW.' },
|
|
111
|
+
[EMS_PROPERTY.genDailyProfile]: {
|
|
112
|
+
dataType: 'xs:string',
|
|
113
|
+
note: 'daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time — the site declares its own curve; we do not invent one.'
|
|
114
|
+
}
|
|
89
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* 하루 형상에서 **이 시각의 비율**을 읽는다 — 없거나 형태가 아니면 `undefined`(발전하지 않는다).
|
|
118
|
+
*
|
|
119
|
+
* 24개가 아니면 받지 않는다: 값이 몇 개인지 짐작해 늘리거나 자르면, 사람이 적은 곡선과 우리가 쓰는 곡선이
|
|
120
|
+
* 달라진다. 0~1 밖의 값도 받지 않는다(정격의 배수로 발전하는 태양광은 없다).
|
|
121
|
+
*/
|
|
122
|
+
export function generationFractionAt(profile, hourOfDay) {
|
|
123
|
+
const parts = String(profile ?? '')
|
|
124
|
+
.split(',')
|
|
125
|
+
.map(v => v.trim())
|
|
126
|
+
.filter(v => v !== '');
|
|
127
|
+
if (parts.length !== 24)
|
|
128
|
+
return undefined;
|
|
129
|
+
const h = Math.floor(hourOfDay);
|
|
130
|
+
if (!Number.isFinite(h) || h < 0 || h > 23)
|
|
131
|
+
return undefined;
|
|
132
|
+
const v = Number(parts[h]);
|
|
133
|
+
if (!Number.isFinite(v) || v < 0 || v > 1)
|
|
134
|
+
return undefined;
|
|
135
|
+
return v;
|
|
136
|
+
}
|
|
90
137
|
export const EMS_TYPES = [
|
|
91
138
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
92
139
|
{
|
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
|
@@ -106,6 +106,7 @@ __export(index_exports, {
|
|
|
106
106
|
foldJobResponses: () => foldJobResponses,
|
|
107
107
|
foldTaskRecords: () => foldTaskRecords,
|
|
108
108
|
gdtiUri: () => gdtiUri,
|
|
109
|
+
generationFractionAt: () => generationFractionAt,
|
|
109
110
|
graiUri: () => graiUri,
|
|
110
111
|
hierarchyOf: () => hierarchyOf,
|
|
111
112
|
inWorkCalendar: () => inWorkCalendar,
|
|
@@ -1757,7 +1758,28 @@ var EMS_PROPERTY = {
|
|
|
1757
1758
|
*
|
|
1758
1759
|
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
|
|
1759
1760
|
*/
|
|
1760
|
-
initialSoc: "storage.initialSoc"
|
|
1761
|
+
initialSoc: "storage.initialSoc",
|
|
1762
|
+
/*
|
|
1763
|
+
* ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
|
|
1764
|
+
*
|
|
1765
|
+
* 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
|
|
1766
|
+
* 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
|
|
1767
|
+
* 없었다(what-if 의 값이 절반만 성립했다).
|
|
1768
|
+
*
|
|
1769
|
+
* **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
|
|
1770
|
+
* 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
|
|
1771
|
+
* (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
|
|
1772
|
+
*
|
|
1773
|
+
* 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
|
|
1774
|
+
*/
|
|
1775
|
+
/** 발전 정격(kW) — 맑은 정오의 최대 출력. */
|
|
1776
|
+
genRatedKW: "generation.ratedKW",
|
|
1777
|
+
/**
|
|
1778
|
+
* 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
|
|
1779
|
+
*
|
|
1780
|
+
* 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
|
|
1781
|
+
*/
|
|
1782
|
+
genDailyProfile: "generation.dailyProfile"
|
|
1761
1783
|
};
|
|
1762
1784
|
var EMS_PROPERTY_SPEC = {
|
|
1763
1785
|
[EMS_PROPERTY.contractKW]: { uom: "kW", dataType: "xs:double", note: "contracted power at the metering point, in kW." },
|
|
@@ -1771,8 +1793,22 @@ var EMS_PROPERTY_SPEC = {
|
|
|
1771
1793
|
[EMS_PROPERTY.maxDischargeKW]: { uom: "kW", dataType: "xs:double", note: "inverter limit on discharge rate, in kW." },
|
|
1772
1794
|
/* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
|
|
1773
1795
|
[EMS_PROPERTY.reserveSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below." },
|
|
1774
|
-
[EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." }
|
|
1796
|
+
[EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." },
|
|
1797
|
+
[EMS_PROPERTY.genRatedKW]: { uom: "kW", dataType: "xs:double", note: "rated generation output at clear-sky noon, in kW." },
|
|
1798
|
+
[EMS_PROPERTY.genDailyProfile]: {
|
|
1799
|
+
dataType: "xs:string",
|
|
1800
|
+
note: "daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time \u2014 the site declares its own curve; we do not invent one."
|
|
1801
|
+
}
|
|
1775
1802
|
};
|
|
1803
|
+
function generationFractionAt(profile, hourOfDay) {
|
|
1804
|
+
const parts = String(profile ?? "").split(",").map((v2) => v2.trim()).filter((v2) => v2 !== "");
|
|
1805
|
+
if (parts.length !== 24) return void 0;
|
|
1806
|
+
const h = Math.floor(hourOfDay);
|
|
1807
|
+
if (!Number.isFinite(h) || h < 0 || h > 23) return void 0;
|
|
1808
|
+
const v = Number(parts[h]);
|
|
1809
|
+
if (!Number.isFinite(v) || v < 0 || v > 1) return void 0;
|
|
1810
|
+
return v;
|
|
1811
|
+
}
|
|
1776
1812
|
var EMS_TYPES = [
|
|
1777
1813
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
1778
1814
|
{
|
|
@@ -3725,12 +3761,35 @@ var FlowEngine = class {
|
|
|
3725
3761
|
acked: [...this._acked]
|
|
3726
3762
|
};
|
|
3727
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
|
+
}
|
|
3728
3786
|
/*
|
|
3729
3787
|
* 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
|
|
3730
3788
|
* severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
|
|
3731
|
-
*
|
|
3789
|
+
* **도메인별 추가 판단은 이것을 override 로 확장한다**(`super.collectAttentions()` 합성) — 수명은
|
|
3790
|
+
* 상위 `computeAttentions` 가 붙여 준다.
|
|
3732
3791
|
*/
|
|
3733
|
-
|
|
3792
|
+
collectAttentions() {
|
|
3734
3793
|
const now = this.now();
|
|
3735
3794
|
const out = deriveAttentions(
|
|
3736
3795
|
{
|
|
@@ -3743,14 +3802,6 @@ var FlowEngine = class {
|
|
|
3743
3802
|
now
|
|
3744
3803
|
// 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
|
|
3745
3804
|
);
|
|
3746
|
-
const present = new Set(out.map((a) => a.id));
|
|
3747
|
-
for (const a of out) {
|
|
3748
|
-
const first = this._attentionSince.get(a.id) ?? now;
|
|
3749
|
-
this._attentionSince.set(a.id, first);
|
|
3750
|
-
a.timeStamp = first;
|
|
3751
|
-
}
|
|
3752
|
-
for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
|
|
3753
|
-
for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
|
|
3754
3805
|
return out;
|
|
3755
3806
|
}
|
|
3756
3807
|
/**
|
|
@@ -6093,6 +6144,28 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6093
6144
|
}
|
|
6094
6145
|
return out;
|
|
6095
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
|
+
}
|
|
6096
6169
|
/**
|
|
6097
6170
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
6098
6171
|
*
|
|
@@ -6319,6 +6392,69 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6319
6392
|
curtailableCandidates: this.curtailableIds()
|
|
6320
6393
|
});
|
|
6321
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
|
+
}
|
|
6322
6458
|
/** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
|
|
6323
6459
|
curtailableIds() {
|
|
6324
6460
|
return (this.boardDef?.equipment ?? []).filter((e) => String(e.kind ?? "") === "curtailable-load").map((e) => e.id);
|
|
@@ -6383,6 +6519,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6383
6519
|
}
|
|
6384
6520
|
}
|
|
6385
6521
|
if (!counted) return;
|
|
6522
|
+
this.deriveGeneration(atMs);
|
|
6386
6523
|
let generated = 0;
|
|
6387
6524
|
let discharged = 0;
|
|
6388
6525
|
let charged = 0;
|
|
@@ -6409,6 +6546,38 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6409
6546
|
this.revision++;
|
|
6410
6547
|
this.judgeOpenWindow(atMs);
|
|
6411
6548
|
}
|
|
6549
|
+
/**
|
|
6550
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
6551
|
+
*
|
|
6552
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
6553
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
6554
|
+
*
|
|
6555
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
6556
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
6557
|
+
*/
|
|
6558
|
+
deriveGeneration(atMs) {
|
|
6559
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6560
|
+
const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
|
|
6561
|
+
if (rated === void 0 || !(rated > 0)) continue;
|
|
6562
|
+
const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
|
|
6563
|
+
const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
|
|
6564
|
+
const fraction = generationFractionAt(profile, hour);
|
|
6565
|
+
const state = this.equipment.get(e.id);
|
|
6566
|
+
if (!state) continue;
|
|
6567
|
+
if (fraction === void 0) {
|
|
6568
|
+
if (Number(state.generatedKW) > 0) {
|
|
6569
|
+
;
|
|
6570
|
+
state.generatedKW = 0;
|
|
6571
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
|
|
6572
|
+
}
|
|
6573
|
+
continue;
|
|
6574
|
+
}
|
|
6575
|
+
const kW = rated * fraction;
|
|
6576
|
+
const before = Number(state.generatedKW);
|
|
6577
|
+
state.generatedKW = kW;
|
|
6578
|
+
if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9)) this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6412
6581
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
6413
6582
|
hasDispatchPolicy(equipmentId) {
|
|
6414
6583
|
for (const e of this.boardDef?.equipment ?? []) {
|
|
@@ -6492,6 +6661,15 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6492
6661
|
}
|
|
6493
6662
|
return void 0;
|
|
6494
6663
|
}
|
|
6664
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
6665
|
+
stringProperty(resource, id) {
|
|
6666
|
+
for (const p of resource?.properties ?? []) {
|
|
6667
|
+
if (p?.id !== id) continue;
|
|
6668
|
+
const v = String(p.value ?? "").trim();
|
|
6669
|
+
if (v) return v;
|
|
6670
|
+
}
|
|
6671
|
+
return void 0;
|
|
6672
|
+
}
|
|
6495
6673
|
tick(dtMs) {
|
|
6496
6674
|
super.tick(dtMs);
|
|
6497
6675
|
const at = this.nowMs();
|
|
@@ -6974,6 +7152,7 @@ function retiredVocabularyIn(line) {
|
|
|
6974
7152
|
foldJobResponses,
|
|
6975
7153
|
foldTaskRecords,
|
|
6976
7154
|
gdtiUri,
|
|
7155
|
+
generationFractionAt,
|
|
6977
7156
|
graiUri,
|
|
6978
7157
|
hierarchyOf,
|
|
6979
7158
|
inWorkCalendar,
|
package/package.json
CHANGED