@operato/twin-kernel 0.7.20 → 0.7.22
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 +11 -0
- package/dist/ems-kernel.d.ts +12 -0
- package/dist/ems-kernel.js +81 -3
- package/dist/ems-profile.d.ts +15 -0
- package/dist/ems-profile.js +49 -2
- package/dist-cjs/index.cjs +90 -3
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -1237,6 +1237,17 @@ export interface MeterPointState {
|
|
|
1237
1237
|
export interface DemandWindowState {
|
|
1238
1238
|
startMs: number;
|
|
1239
1239
|
endMs: number;
|
|
1240
|
+
/**
|
|
1241
|
+
* 이 구간의 **최대가 찍힌 순간**의 지점별 kW — 요금이 걸린 수는 kW 다.
|
|
1242
|
+
*
|
|
1243
|
+
* 나중에 계산할 수 없다: 커널은 표본을 보관하지 않으므로 그 순간이 지나면 각 지점이 얼마였는지 알 길이
|
|
1244
|
+
* 없다. 그래서 최대가 갱신되는 순간에 찍어 둔다. 「어느 분기를 깎아야 피크가 내려가나」의 유일한 근거다
|
|
1245
|
+
* (전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
|
|
1246
|
+
*/
|
|
1247
|
+
pointsAtPeak?: {
|
|
1248
|
+
id: string;
|
|
1249
|
+
kW: number;
|
|
1250
|
+
}[];
|
|
1240
1251
|
/**
|
|
1241
1252
|
* 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다).
|
|
1242
1253
|
*
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -140,6 +140,16 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
140
140
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
141
141
|
*/
|
|
142
142
|
private deriveLoad;
|
|
143
|
+
/**
|
|
144
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
145
|
+
*
|
|
146
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
147
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
148
|
+
*
|
|
149
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
150
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
151
|
+
*/
|
|
152
|
+
private deriveGeneration;
|
|
143
153
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
144
154
|
private hasDispatchPolicy;
|
|
145
155
|
/**
|
|
@@ -163,6 +173,8 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
163
173
|
private emitEnergyEquipment;
|
|
164
174
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
165
175
|
private numberProperty;
|
|
176
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
177
|
+
private stringProperty;
|
|
166
178
|
tick(dtMs: number): void;
|
|
167
179
|
getSnapshot(): StateSnapshot;
|
|
168
180
|
}
|
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 { 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분)에 맞춘다. */
|
|
@@ -300,8 +300,25 @@ export class EmsKernel extends FlowEngine {
|
|
|
300
300
|
*/
|
|
301
301
|
const roots = this.rootMeterIds();
|
|
302
302
|
const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
|
|
303
|
-
if (w.maxKW === undefined || total > w.maxKW)
|
|
303
|
+
if (w.maxKW === undefined || total > w.maxKW) {
|
|
304
304
|
w.maxKW = total;
|
|
305
|
+
/*
|
|
306
|
+
* ── 피크가 갱신된 **그 순간**의 지점별 kW 를 붙잡는다 (2026-08-18) ─────────
|
|
307
|
+
*
|
|
308
|
+
* 요금이 매겨지는 수는 kW(최대수요)이고 전력량(kWh)이 아니다. 그런데 지점별로 남기던 것은
|
|
309
|
+
* 구간 전력량뿐이라, 「어느 분기를 깎아야 피크가 내려가나」에 답할 수 없었다 — 전력량이 큰 분기가
|
|
310
|
+
* 피크를 만든 분기와 다를 수 있다(하루 종일 조금씩 쓰는 부하 vs 잠깐 크게 쓰는 부하).
|
|
311
|
+
*
|
|
312
|
+
* 나중에 계산할 수 없는 값이다: 표본을 보관하지 않으므로 그 순간이 지나면 각 지점이 얼마였는지
|
|
313
|
+
* 알 길이 없다. 그래서 **갱신되는 순간에** 찍는다.
|
|
314
|
+
*
|
|
315
|
+
* 뿌리만이 아니라 **모든 지점**을 찍는다: 뿌리 합이 피크를 만들지만, 그 피크를 누가 만들었는지는
|
|
316
|
+
* 하위 계량이 답한다. kW 를 모르는 지점은 넣지 않는다(0 으로 만들지 않는다).
|
|
317
|
+
*/
|
|
318
|
+
w.pointsAtPeak = [...this.points.values()]
|
|
319
|
+
.filter(p => Number.isFinite(Number(p.kW)))
|
|
320
|
+
.map(p => ({ id: p.id, kW: Number(p.kW) }));
|
|
321
|
+
}
|
|
305
322
|
w.samples++;
|
|
306
323
|
w.meanKW = (w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples);
|
|
307
324
|
}
|
|
@@ -387,6 +404,11 @@ export class EmsKernel extends FlowEngine {
|
|
|
387
404
|
* `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
|
|
388
405
|
*/
|
|
389
406
|
...(pointShares.length ? { points: pointShares } : {}),
|
|
407
|
+
/*
|
|
408
|
+
* 피크 순간의 지점별 kW — 요금이 걸린 수는 kW 다. 이것이 없으면 저널을 읽는 쪽은 「어느 분기를
|
|
409
|
+
* 깎아야 피크가 내려가나」에 답할 수 없다(전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
|
|
410
|
+
*/
|
|
411
|
+
...(w.pointsAtPeak?.length ? { pointsAtPeak: w.pointsAtPeak } : {}),
|
|
390
412
|
/*
|
|
391
413
|
* **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
|
|
392
414
|
* (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
|
|
@@ -533,6 +555,14 @@ export class EmsKernel extends FlowEngine {
|
|
|
533
555
|
* 음수는 **역송**이지 음의 수요가 아니다. 계통 수요는 0 에서 멈추고, 남는 발전은 그 설비의
|
|
534
556
|
* `exportKW` 가 이미 사실로 말한다(여기서 다시 만들지 않는다).
|
|
535
557
|
*/
|
|
558
|
+
/*
|
|
559
|
+
* ── 선언된 발전을 먼저 만든다 (2026-08-18) ─────────────────────────────────
|
|
560
|
+
* 정격과 하루 형상을 선언한 설비는 이 시각의 비율만큼 발전한다. 형상을 우리가 지어내지 않으므로
|
|
561
|
+
* (위도·날씨로 곡선을 만들면 그 수의 출처를 설명할 수 없다) 선언이 없으면 발전하지 않는다.
|
|
562
|
+
*
|
|
563
|
+
* 미러에서는 여기까지 오지 않는다(위에서 관측이 진실이라 돌아간다) — 계측이 발전량을 말해 준다.
|
|
564
|
+
*/
|
|
565
|
+
this.deriveGeneration(atMs);
|
|
536
566
|
let generated = 0;
|
|
537
567
|
let discharged = 0;
|
|
538
568
|
let charged = 0;
|
|
@@ -582,6 +612,43 @@ export class EmsKernel extends FlowEngine {
|
|
|
582
612
|
this.revision++;
|
|
583
613
|
this.judgeOpenWindow(atMs);
|
|
584
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
617
|
+
*
|
|
618
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
619
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
620
|
+
*
|
|
621
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
622
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
623
|
+
*/
|
|
624
|
+
deriveGeneration(atMs) {
|
|
625
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
626
|
+
const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
|
|
627
|
+
if (rated === undefined || !(rated > 0))
|
|
628
|
+
continue;
|
|
629
|
+
const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
|
|
630
|
+
const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
|
|
631
|
+
const fraction = generationFractionAt(profile, hour);
|
|
632
|
+
const state = this.equipment.get(e.id);
|
|
633
|
+
if (!state)
|
|
634
|
+
continue;
|
|
635
|
+
if (fraction === undefined) {
|
|
636
|
+
/* 형상이 없거나 형태가 아니면 발전하지 않는다 — 낡은 값을 남기지 않는다(지난 시각을 말하게 된다). */
|
|
637
|
+
if (Number(state.generatedKW) > 0) {
|
|
638
|
+
;
|
|
639
|
+
state.generatedKW = 0;
|
|
640
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
|
|
641
|
+
}
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const kW = rated * fraction;
|
|
645
|
+
const before = Number(state.generatedKW);
|
|
646
|
+
state.generatedKW = kW;
|
|
647
|
+
/* 값이 실제로 바뀔 때만 사실을 낸다 — 매 틱 같은 값을 되풀면 저널이 소음으로 찬다. */
|
|
648
|
+
if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9))
|
|
649
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
585
652
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
586
653
|
hasDispatchPolicy(equipmentId) {
|
|
587
654
|
for (const e of this.boardDef?.equipment ?? []) {
|
|
@@ -687,6 +754,17 @@ export class EmsKernel extends FlowEngine {
|
|
|
687
754
|
}
|
|
688
755
|
return undefined;
|
|
689
756
|
}
|
|
757
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
758
|
+
stringProperty(resource, id) {
|
|
759
|
+
for (const p of resource?.properties ?? []) {
|
|
760
|
+
if (p?.id !== id)
|
|
761
|
+
continue;
|
|
762
|
+
const v = String(p.value ?? '').trim();
|
|
763
|
+
if (v)
|
|
764
|
+
return v;
|
|
765
|
+
}
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
690
768
|
tick(dtMs) {
|
|
691
769
|
super.tick(dtMs);
|
|
692
770
|
/*
|
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-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
|
{
|
|
@@ -6184,7 +6220,10 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6184
6220
|
if (kW !== void 0) {
|
|
6185
6221
|
const roots = this.rootMeterIds();
|
|
6186
6222
|
const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
|
|
6187
|
-
if (w.maxKW === void 0 || total > w.maxKW)
|
|
6223
|
+
if (w.maxKW === void 0 || total > w.maxKW) {
|
|
6224
|
+
w.maxKW = total;
|
|
6225
|
+
w.pointsAtPeak = [...this.points.values()].filter((p) => Number.isFinite(Number(p.kW))).map((p) => ({ id: p.id, kW: Number(p.kW) }));
|
|
6226
|
+
}
|
|
6188
6227
|
w.samples++;
|
|
6189
6228
|
w.meanKW = w.meanKW === void 0 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
|
|
6190
6229
|
}
|
|
@@ -6262,6 +6301,11 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6262
6301
|
* `root` 는 계층 판정을 우리가 말해 주는 것이다 — 소비처가 같은 규칙을 다시 쓰면 갈라진다.
|
|
6263
6302
|
*/
|
|
6264
6303
|
...pointShares.length ? { points: pointShares } : {},
|
|
6304
|
+
/*
|
|
6305
|
+
* 피크 순간의 지점별 kW — 요금이 걸린 수는 kW 다. 이것이 없으면 저널을 읽는 쪽은 「어느 분기를
|
|
6306
|
+
* 깎아야 피크가 내려가나」에 답할 수 없다(전력량이 큰 분기와 피크를 만든 분기는 다를 수 있다).
|
|
6307
|
+
*/
|
|
6308
|
+
...w.pointsAtPeak?.length ? { pointsAtPeak: w.pointsAtPeak } : {},
|
|
6265
6309
|
/*
|
|
6266
6310
|
* **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
|
|
6267
6311
|
* (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
|
|
@@ -6375,6 +6419,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6375
6419
|
}
|
|
6376
6420
|
}
|
|
6377
6421
|
if (!counted) return;
|
|
6422
|
+
this.deriveGeneration(atMs);
|
|
6378
6423
|
let generated = 0;
|
|
6379
6424
|
let discharged = 0;
|
|
6380
6425
|
let charged = 0;
|
|
@@ -6401,6 +6446,38 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6401
6446
|
this.revision++;
|
|
6402
6447
|
this.judgeOpenWindow(atMs);
|
|
6403
6448
|
}
|
|
6449
|
+
/**
|
|
6450
|
+
* 선언된 발전을 만든다 — **형상은 현장이 적는다.**
|
|
6451
|
+
*
|
|
6452
|
+
* 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
|
|
6453
|
+
* 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
|
|
6454
|
+
*
|
|
6455
|
+
* 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
|
|
6456
|
+
* 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
|
|
6457
|
+
*/
|
|
6458
|
+
deriveGeneration(atMs) {
|
|
6459
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6460
|
+
const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
|
|
6461
|
+
if (rated === void 0 || !(rated > 0)) continue;
|
|
6462
|
+
const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
|
|
6463
|
+
const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
|
|
6464
|
+
const fraction = generationFractionAt(profile, hour);
|
|
6465
|
+
const state = this.equipment.get(e.id);
|
|
6466
|
+
if (!state) continue;
|
|
6467
|
+
if (fraction === void 0) {
|
|
6468
|
+
if (Number(state.generatedKW) > 0) {
|
|
6469
|
+
;
|
|
6470
|
+
state.generatedKW = 0;
|
|
6471
|
+
this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
|
|
6472
|
+
}
|
|
6473
|
+
continue;
|
|
6474
|
+
}
|
|
6475
|
+
const kW = rated * fraction;
|
|
6476
|
+
const before = Number(state.generatedKW);
|
|
6477
|
+
state.generatedKW = kW;
|
|
6478
|
+
if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9)) this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
|
|
6479
|
+
}
|
|
6480
|
+
}
|
|
6404
6481
|
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
6405
6482
|
hasDispatchPolicy(equipmentId) {
|
|
6406
6483
|
for (const e of this.boardDef?.equipment ?? []) {
|
|
@@ -6484,6 +6561,15 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6484
6561
|
}
|
|
6485
6562
|
return void 0;
|
|
6486
6563
|
}
|
|
6564
|
+
/** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
|
|
6565
|
+
stringProperty(resource, id) {
|
|
6566
|
+
for (const p of resource?.properties ?? []) {
|
|
6567
|
+
if (p?.id !== id) continue;
|
|
6568
|
+
const v = String(p.value ?? "").trim();
|
|
6569
|
+
if (v) return v;
|
|
6570
|
+
}
|
|
6571
|
+
return void 0;
|
|
6572
|
+
}
|
|
6487
6573
|
tick(dtMs) {
|
|
6488
6574
|
super.tick(dtMs);
|
|
6489
6575
|
const at = this.nowMs();
|
|
@@ -6966,6 +7052,7 @@ function retiredVocabularyIn(line) {
|
|
|
6966
7052
|
foldJobResponses,
|
|
6967
7053
|
foldTaskRecords,
|
|
6968
7054
|
gdtiUri,
|
|
7055
|
+
generationFractionAt,
|
|
6969
7056
|
graiUri,
|
|
6970
7057
|
hierarchyOf,
|
|
6971
7058
|
inWorkCalendar,
|
package/package.json
CHANGED