@operato/twin-kernel 0.7.14 → 0.7.16
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 +54 -1
- package/dist/ems-kernel.d.ts +21 -0
- package/dist/ems-kernel.js +149 -5
- package/dist/ems-profile.d.ts +8 -0
- package/dist/ems-profile.js +23 -1
- package/dist/energy-attribution.d.ts +10 -0
- package/dist/energy-attribution.js +5 -0
- package/dist/flow-engine.d.ts +30 -1
- package/dist/flow-engine.js +79 -1
- package/dist-cjs/index.cjs +188 -5
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -1210,8 +1210,27 @@ export interface MeterPointState {
|
|
|
1210
1210
|
export interface DemandWindowState {
|
|
1211
1211
|
startMs: number;
|
|
1212
1212
|
endMs: number;
|
|
1213
|
-
/**
|
|
1213
|
+
/**
|
|
1214
|
+
* 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다).
|
|
1215
|
+
*
|
|
1216
|
+
* **계통에서 끌어온 수요**다(요금이 이것에 매겨진다). 현장에 발전·축전이 있으면 설비가 끌어당긴
|
|
1217
|
+
* 양과 다르다 — 그 차이는 `grossMaxKW` 와의 간격으로 읽는다.
|
|
1218
|
+
*/
|
|
1214
1219
|
maxKW?: number;
|
|
1220
|
+
/**
|
|
1221
|
+
* 설비가 **끌어당긴** 최대(발전·방전 차감 전, 충전 포함).
|
|
1222
|
+
*
|
|
1223
|
+
* ── 왜 둘을 함께 두나 (2026-08-17) ────────────────────────────────────────
|
|
1224
|
+
* 요금은 계통 수요(`maxKW`)로 매겨지지만, 「무엇이 그 수요를 만들었나」는 설비 부하가 답한다.
|
|
1225
|
+
* 하나만 두면 둘 중 하나를 잃는다: 순수요만 두면 태양광이 잠깐 가려졌을 때 왜 피크가 올랐는지 설명할
|
|
1226
|
+
* 수 없고, 총부하만 두면 요금이 실제보다 커 보인다.
|
|
1227
|
+
*
|
|
1228
|
+
* 그리고 **이 둘의 간격이 곧 깎인 양**이다(피크 셰이빙). 같은 표본에서 나온 두 최대값이므로 비교가
|
|
1229
|
+
* 성립한다 — 서로 다른 창의 값을 견주는 것이 아니다.
|
|
1230
|
+
*
|
|
1231
|
+
* 발전·축전을 선언하지 않은 현장에서는 `maxKW` 와 같다(그때는 차감할 것이 없다).
|
|
1232
|
+
*/
|
|
1233
|
+
grossMaxKW?: number;
|
|
1215
1234
|
/** 표본 평균 부하 — 요금 산정의 평균 수요에 대응한다. */
|
|
1216
1235
|
meanKW?: number;
|
|
1217
1236
|
/** **kW 를 실은** 표본 수 — 부하 판정의 근거 수다(받은 표본 전체가 아니다). */
|
|
@@ -1414,6 +1433,40 @@ export interface ScenarioDef {
|
|
|
1414
1433
|
speed?: number;
|
|
1415
1434
|
horizon?: number;
|
|
1416
1435
|
generators: GeneratorSpec[];
|
|
1436
|
+
/**
|
|
1437
|
+
* **가정한 개입** — 시각을 가진 행동들(what-if).
|
|
1438
|
+
*
|
|
1439
|
+
* ── 왜 시나리오에 있나 (2026-08-17) ───────────────────────────────────────
|
|
1440
|
+
* 시나리오는 이미 「우리가 가정한 미래」다. 「30분 뒤 이 설비를 세운다」는 정확히 그 미래의 일부이므로
|
|
1441
|
+
* 여기 있어야 한다. 호스트가 fork 를 굴리며 밖에서 커맨드를 쏘는 방법도 되지만, 그러면 가정이 fork
|
|
1442
|
+
* 밖에 남아 **다른 소비처가 같은 미래를 재생할 수 없다**(예측·백테스트·AI·보드가 각자 타이밍을 다시
|
|
1443
|
+
* 짜야 한다). fork 가 자기완결이면 같은 선언 하나로 어디서든 같은 미래가 나온다.
|
|
1444
|
+
*
|
|
1445
|
+
* `atMs` 는 **시나리오를 시작한 시점부터의 경과**다(절대 시각이 아니다) — fork 는 언제 갈라져도
|
|
1446
|
+
* 같은 가정을 같은 순서로 겪어야 하고, 절대 시각으로 두면 갈라진 시점에 따라 다른 미래가 된다.
|
|
1447
|
+
*
|
|
1448
|
+
* `kind` 는 커맨드 어휘(`CMD`)를 그대로 쓴다 — 개입은 새 종류의 사건이 아니라 **행동**이고, 그 어휘는
|
|
1449
|
+
* 이미 있다. 커널이 모르는 종류는 거절하고 그 사실을 남긴다(조용히 넘기면 「걸었는데 왜 안 바뀌나」를
|
|
1450
|
+
* 아무도 답할 수 없다).
|
|
1451
|
+
*/
|
|
1452
|
+
interventions?: ScenarioIntervention[];
|
|
1453
|
+
}
|
|
1454
|
+
/** 가정한 개입 하나 — 시각 + 행동. */
|
|
1455
|
+
export interface ScenarioIntervention {
|
|
1456
|
+
/** 시나리오 시작부터의 경과(ms). 0 이면 시작하는 순간. */
|
|
1457
|
+
atMs: number;
|
|
1458
|
+
/** 커맨드 이름(`CMD` 의 값) — 예: `resource.hold`. */
|
|
1459
|
+
kind: string;
|
|
1460
|
+
args?: Record<string, unknown>;
|
|
1461
|
+
}
|
|
1462
|
+
/** 개입이 어떻게 됐나 — 걸린 것과 거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
1463
|
+
export interface InterventionOutcome {
|
|
1464
|
+
atMs: number;
|
|
1465
|
+
kind: string;
|
|
1466
|
+
args?: Record<string, unknown>;
|
|
1467
|
+
applied: boolean;
|
|
1468
|
+
/** 거절 이유(커맨드가 낸 코드). 걸렸으면 없다. */
|
|
1469
|
+
refusedCode?: string;
|
|
1417
1470
|
}
|
|
1418
1471
|
export interface ScenarioControl {
|
|
1419
1472
|
load(def: ScenarioDef): void;
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -129,6 +129,27 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
129
129
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
130
130
|
*/
|
|
131
131
|
private deriveLoad;
|
|
132
|
+
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
133
|
+
private hasDispatchPolicy;
|
|
134
|
+
/**
|
|
135
|
+
* 선언된 정책으로 축전지를 방전시킨다 — **선언이 없으면 아무것도 하지 않는다.**
|
|
136
|
+
*
|
|
137
|
+
* 돌려주는 값은 이번 틱에 계통에서 덜어낸 kW 다. 부작용으로 그 설비의 `dischargeKW`·`soc` 를 고치고
|
|
138
|
+
* 사실(`energy.equipment`)을 낸다 — 저널을 읽는 쪽이 「배터리가 무엇을 했나」를 되짚을 수 있어야 한다.
|
|
139
|
+
*
|
|
140
|
+
* ── 무엇을 하지 않나 ──────────────────────────────────────────────────────
|
|
141
|
+
* · 충전 계획을 만들지 않는다(언제 채울지는 요금 시간대의 일이고, 그 모델은 아직 없다).
|
|
142
|
+
* · 용량·임계·최대율이 하나라도 없으면 방전하지 않는다 — 짐작한 수로 피크를 깎지 않는다.
|
|
143
|
+
* · 미러 트윈에서는 불리지 않는다(호출자가 이미 관측 모드를 걸러낸다).
|
|
144
|
+
*/
|
|
145
|
+
private dispatchStorage;
|
|
146
|
+
/**
|
|
147
|
+
* 파생된 설비 에너지 상태를 사실로 낸다 — **만든 값임을 밝힌다.**
|
|
148
|
+
*
|
|
149
|
+
* 계측으로 들어온 것과 같은 자리에 두면서 종류를 적지 않으면, 저널을 읽는 쪽이 우리가 계산한 방전을
|
|
150
|
+
* 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
|
|
151
|
+
*/
|
|
152
|
+
private emitEnergyEquipment;
|
|
132
153
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
133
154
|
private numberProperty;
|
|
134
155
|
tick(dtMs: number): void;
|
package/dist/ems-kernel.js
CHANGED
|
@@ -309,6 +309,9 @@ export class EmsKernel extends FlowEngine {
|
|
|
309
309
|
startMs: w.startMs,
|
|
310
310
|
endMs: w.endMs,
|
|
311
311
|
...(w.maxKW !== undefined ? { maxKW: w.maxKW } : {}),
|
|
312
|
+
/* 총부하도 사실로 남긴다 — 이것이 없으면 저널을 읽는 쪽은 「무엇이 깎였나」를 되짚을 수 없다
|
|
313
|
+
(요금은 순수요로 매겨지지만, 그 수요를 만든 것은 설비 부하다). */
|
|
314
|
+
...(w.grossMaxKW !== undefined ? { grossMaxKW: w.grossMaxKW } : {}),
|
|
312
315
|
...(w.meanKW !== undefined ? { meanKW: w.meanKW } : {}),
|
|
313
316
|
samples: w.samples,
|
|
314
317
|
...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
|
|
@@ -420,7 +423,7 @@ export class EmsKernel extends FlowEngine {
|
|
|
420
423
|
* ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
|
|
421
424
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
422
425
|
*/
|
|
423
|
-
deriveLoad(atMs) {
|
|
426
|
+
deriveLoad(atMs, dtMs) {
|
|
424
427
|
if (this.observing)
|
|
425
428
|
return; // 미러 — 계측이 진실이다
|
|
426
429
|
let total = 0;
|
|
@@ -450,17 +453,157 @@ export class EmsKernel extends FlowEngine {
|
|
|
450
453
|
}
|
|
451
454
|
if (!counted)
|
|
452
455
|
return; // 셀 것이 하나도 없으면 구간에 값을 넣지 않는다(0 을 주장하지 않는다)
|
|
456
|
+
/*
|
|
457
|
+
* ── 계통에서 끌어오는 것은 **순수요**다 (2026-08-17) ──────────────────────
|
|
458
|
+
*
|
|
459
|
+
* 그동안 이 함수는 소비만 셌다. 그래서 태양광이 400kW 를 만들고 배터리가 300kW 를 내보내도 계통
|
|
460
|
+
* 수요가 한 톨도 줄지 않았다 — 「배터리로 피크를 깎는다」가 시뮬에서 성립하지 않는 상태였고,
|
|
461
|
+
* 그 위에 세운 요금·계약 판정도 그만큼 틀렸다.
|
|
462
|
+
*
|
|
463
|
+
* 요금이 매겨지는 것은 계량 지점을 지나는 전력이다:
|
|
464
|
+
*
|
|
465
|
+
* 순수요 = 설비 부하 + 충전 − 발전 − 방전
|
|
466
|
+
*
|
|
467
|
+
* 충전이 **더해지는** 이유: 배터리를 채우는 동안 그 전기도 계통에서 온다(피크를 만들 수 있다 —
|
|
468
|
+
* 잘못된 시각에 충전하면 깎으려던 피크를 오히려 키운다. 그 사실이 값에 보여야 한다).
|
|
469
|
+
*
|
|
470
|
+
* 음수는 **역송**이지 음의 수요가 아니다. 계통 수요는 0 에서 멈추고, 남는 발전은 그 설비의
|
|
471
|
+
* `exportKW` 가 이미 사실로 말한다(여기서 다시 만들지 않는다).
|
|
472
|
+
*/
|
|
473
|
+
let generated = 0;
|
|
474
|
+
let discharged = 0;
|
|
475
|
+
let charged = 0;
|
|
476
|
+
for (const m of this.equipment.values()) {
|
|
477
|
+
const g = Number(m.generatedKW);
|
|
478
|
+
if (Number.isFinite(g) && g > 0)
|
|
479
|
+
generated += g;
|
|
480
|
+
const c = Number(m.chargeKW);
|
|
481
|
+
if (Number.isFinite(c) && c > 0)
|
|
482
|
+
charged += c;
|
|
483
|
+
/*
|
|
484
|
+
* **정책이 정하는 방전은 여기서 세지 않는다** (2026-08-17 시험이 잡았다).
|
|
485
|
+
*
|
|
486
|
+
* 세면 직전 틱에 우리가 만든 방전이 이번 틱의 입력으로 돌아와, 정책은 「임계 아래다, 쓸 이유가
|
|
487
|
+
* 없다」고 판단하고 방전을 멈춘다. 그러면 SOC 는 한 번만 줄고 피크는 깎인 채로 굳는다 —
|
|
488
|
+
* 배터리가 공짜로 무한히 일하는 그림이다.
|
|
489
|
+
*
|
|
490
|
+
* 계측으로 들어온 방전(정책이 없는 축전지)은 사실이므로 그대로 센다.
|
|
491
|
+
*/
|
|
492
|
+
if (this.hasDispatchPolicy(m.id))
|
|
493
|
+
continue;
|
|
494
|
+
const d = Number(m.dischargeKW);
|
|
495
|
+
if (Number.isFinite(d) && d > 0)
|
|
496
|
+
discharged += d;
|
|
497
|
+
}
|
|
498
|
+
const gross = total + charged;
|
|
499
|
+
/*
|
|
500
|
+
* ── 선언된 방전 정책이 있으면 여기서 방전한다 (2026-08-17) ────────────────
|
|
501
|
+
*
|
|
502
|
+
* 임계를 넘는 만큼만, 최대율 안에서, 예비 SOC 위에 남은 에너지로만. **SOC 가 실제로 줄고**, 다 쓰면
|
|
503
|
+
* 방전이 멈춰 피크가 다시 올라온다 — 그것이 사실이다. 줄지 않으면 트윈이 「무한히 깎을 수 있다」고
|
|
504
|
+
* 주장하게 되고, 그 위에 세운 요금 절감액은 짐작이 된다.
|
|
505
|
+
*/
|
|
506
|
+
const dispatched = this.dispatchStorage(Math.max(0, gross - generated - discharged), atMs, dtMs);
|
|
507
|
+
const net = Math.max(0, gross - generated - discharged - dispatched);
|
|
453
508
|
this.closeDue(atMs);
|
|
454
509
|
this.openWindow(atMs);
|
|
455
510
|
const w = this.open;
|
|
456
511
|
w.derived = true;
|
|
457
|
-
|
|
458
|
-
|
|
512
|
+
/* 요금이 보는 수는 순수요다. 총부하는 「무엇이 그 수요를 만들었나」를 위해 함께 남긴다. */
|
|
513
|
+
if (w.maxKW === undefined || net > w.maxKW)
|
|
514
|
+
w.maxKW = net;
|
|
515
|
+
if (w.grossMaxKW === undefined || gross > w.grossMaxKW)
|
|
516
|
+
w.grossMaxKW = gross;
|
|
459
517
|
w.samples++;
|
|
460
|
-
w.meanKW = w.meanKW === undefined ?
|
|
518
|
+
w.meanKW = w.meanKW === undefined ? net : (w.meanKW * (w.samples - 1) + net) / w.samples;
|
|
461
519
|
this.revision++;
|
|
462
520
|
this.judgeOpenWindow(atMs);
|
|
463
521
|
}
|
|
522
|
+
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
523
|
+
hasDispatchPolicy(equipmentId) {
|
|
524
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
525
|
+
if (e.id !== equipmentId)
|
|
526
|
+
continue;
|
|
527
|
+
return (this.numberProperty(e, EMS_PROPERTY.capacityKWh) !== undefined &&
|
|
528
|
+
this.numberProperty(e, EMS_PROPERTY.dischargeAboveKW) !== undefined &&
|
|
529
|
+
this.numberProperty(e, EMS_PROPERTY.maxDischargeKW) !== undefined);
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 선언된 정책으로 축전지를 방전시킨다 — **선언이 없으면 아무것도 하지 않는다.**
|
|
535
|
+
*
|
|
536
|
+
* 돌려주는 값은 이번 틱에 계통에서 덜어낸 kW 다. 부작용으로 그 설비의 `dischargeKW`·`soc` 를 고치고
|
|
537
|
+
* 사실(`energy.equipment`)을 낸다 — 저널을 읽는 쪽이 「배터리가 무엇을 했나」를 되짚을 수 있어야 한다.
|
|
538
|
+
*
|
|
539
|
+
* ── 무엇을 하지 않나 ──────────────────────────────────────────────────────
|
|
540
|
+
* · 충전 계획을 만들지 않는다(언제 채울지는 요금 시간대의 일이고, 그 모델은 아직 없다).
|
|
541
|
+
* · 용량·임계·최대율이 하나라도 없으면 방전하지 않는다 — 짐작한 수로 피크를 깎지 않는다.
|
|
542
|
+
* · 미러 트윈에서는 불리지 않는다(호출자가 이미 관측 모드를 걸러낸다).
|
|
543
|
+
*/
|
|
544
|
+
dispatchStorage(demandBeforeStorageKW, atMs, dtMs) {
|
|
545
|
+
let dispatched = 0;
|
|
546
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
547
|
+
const capacity = this.numberProperty(e, EMS_PROPERTY.capacityKWh);
|
|
548
|
+
const above = this.numberProperty(e, EMS_PROPERTY.dischargeAboveKW);
|
|
549
|
+
const maxRate = this.numberProperty(e, EMS_PROPERTY.maxDischargeKW);
|
|
550
|
+
if (capacity === undefined || above === undefined || maxRate === undefined)
|
|
551
|
+
continue;
|
|
552
|
+
if (!(capacity > 0) || !(maxRate > 0))
|
|
553
|
+
continue;
|
|
554
|
+
const state = this.equipment.get(e.id);
|
|
555
|
+
if (!state)
|
|
556
|
+
continue;
|
|
557
|
+
const over = demandBeforeStorageKW - dispatched - above;
|
|
558
|
+
if (over <= 0) {
|
|
559
|
+
/* 임계 아래 — 쓸 이유가 없다. 낡은 방전값을 남기지 않는다(상태가 지난 틱을 말하게 된다). */
|
|
560
|
+
if (Number(state.dischargeKW) > 0) {
|
|
561
|
+
state.dischargeKW = 0;
|
|
562
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, ...(Number.isFinite(Number(state.soc)) ? { soc: Number(state.soc) } : {}) });
|
|
563
|
+
}
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const reserve = this.numberProperty(e, EMS_PROPERTY.reserveSoc) ?? 0;
|
|
567
|
+
/* SOC 를 모르면 방전하지 않는다 — 남은 에너지를 모른 채 쓰면 얼마나 버티는지 지어내는 것이다. */
|
|
568
|
+
const soc = Number(state.soc);
|
|
569
|
+
if (!Number.isFinite(soc))
|
|
570
|
+
continue;
|
|
571
|
+
const usableKWh = ((soc - reserve) / 100) * capacity;
|
|
572
|
+
if (!(usableKWh > 0)) {
|
|
573
|
+
/* 다 썼다 — 방전을 멈추고 그 사실을 남긴다(피크가 다시 올라오는 것이 사실이다). */
|
|
574
|
+
if (Number(state.dischargeKW) > 0) {
|
|
575
|
+
state.dischargeKW = 0;
|
|
576
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, soc });
|
|
577
|
+
}
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
/*
|
|
581
|
+
* 이 틱에 낼 수 있는 kW — 남은 에너지를 이 틱 길이로 나눈 값이 상한이다. 틱이 짧으면 이 상한은
|
|
582
|
+
* 아주 크므로 실제로 묶는 것은 **누적**이다(SOC 가 줄면서 곧 위 검사에 걸린다).
|
|
583
|
+
*/
|
|
584
|
+
const dtHours = dtMs / 3_600_000;
|
|
585
|
+
const rateCap = dtHours > 0 ? usableKWh / dtHours : maxRate;
|
|
586
|
+
const kW = Math.min(maxRate, over, rateCap);
|
|
587
|
+
if (!(kW > 0))
|
|
588
|
+
continue;
|
|
589
|
+
const drained = dtHours > 0 ? ((kW * dtHours) / capacity) * 100 : 0;
|
|
590
|
+
const nextSoc = Math.max(reserve, soc - drained);
|
|
591
|
+
state.dischargeKW = kW;
|
|
592
|
+
state.soc = nextSoc;
|
|
593
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: kW, soc: nextSoc });
|
|
594
|
+
dispatched += kW;
|
|
595
|
+
}
|
|
596
|
+
return dispatched;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 파생된 설비 에너지 상태를 사실로 낸다 — **만든 값임을 밝힌다.**
|
|
600
|
+
*
|
|
601
|
+
* 계측으로 들어온 것과 같은 자리에 두면서 종류를 적지 않으면, 저널을 읽는 쪽이 우리가 계산한 방전을
|
|
602
|
+
* 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
|
|
603
|
+
*/
|
|
604
|
+
emitEnergyEquipment(equipmentId, atMs, fields) {
|
|
605
|
+
this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
|
|
606
|
+
}
|
|
464
607
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
465
608
|
numberProperty(resource, id) {
|
|
466
609
|
for (const p of resource?.properties ?? []) {
|
|
@@ -484,7 +627,8 @@ export class EmsKernel extends FlowEngine {
|
|
|
484
627
|
* 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
|
|
485
628
|
*/
|
|
486
629
|
const at = this.nowMs();
|
|
487
|
-
|
|
630
|
+
/* 틱 길이를 함께 넘긴다 — 방전량을 에너지로 바꿀 때 필요하다(필드로 숨기면 어디서 온 값인지 흐려진다). */
|
|
631
|
+
this.deriveLoad(at, dtMs);
|
|
488
632
|
this.closeDue(at);
|
|
489
633
|
}
|
|
490
634
|
getSnapshot() {
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -22,5 +22,13 @@ export declare const EMS_PROPERTY: {
|
|
|
22
22
|
readonly energyChargePerKWh: "tariff.energyChargePerKWh";
|
|
23
23
|
/** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
|
|
24
24
|
readonly currency: "tariff.currency";
|
|
25
|
+
/** 축전지 용량(kWh) — SOC 를 에너지로 바꾸는 값. 없으면 방전을 만들지 않는다. */
|
|
26
|
+
readonly capacityKWh: "storage.capacityKWh";
|
|
27
|
+
/** 이 순수요를 넘으면 방전한다(kW) — 피크 억제 임계. */
|
|
28
|
+
readonly dischargeAboveKW: "dispatch.dischargeAboveKW";
|
|
29
|
+
/** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
|
|
30
|
+
readonly maxDischargeKW: "dispatch.maxDischargeKW";
|
|
31
|
+
/** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
|
|
32
|
+
readonly reserveSoc: "dispatch.reserveSoc";
|
|
25
33
|
};
|
|
26
34
|
export declare const EMS_TYPES: TwinTypeInfo[];
|
package/dist/ems-profile.js
CHANGED
|
@@ -39,7 +39,29 @@ export const EMS_PROPERTY = {
|
|
|
39
39
|
/** 사용량 단가 — 1kWh 당. */
|
|
40
40
|
energyChargePerKWh: 'tariff.energyChargePerKWh',
|
|
41
41
|
/** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
|
|
42
|
-
currency: 'tariff.currency'
|
|
42
|
+
currency: 'tariff.currency',
|
|
43
|
+
/*
|
|
44
|
+
* ── 축전지의 용량과 방전 정책 (2026-08-17) ─────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* 「배터리로 피크를 깎는다」를 시뮬이 보이려면 방전을 만들어야 하고, 그것은 **선언에서** 나와야 한다.
|
|
47
|
+
* 임계·최대율·예비를 우리가 정하면 그 수가 어디서 왔는지 아무도 설명할 수 없다.
|
|
48
|
+
*
|
|
49
|
+
* ── 선언한다는 것은 「그 자동화가 있다」는 주장이다 ─────────────────────────
|
|
50
|
+
* 이 정책을 모델에 적으면 **라이브가 아닌 모든 구동이 그 규칙으로 돈다.** 현장에 BESS 제어기가 없는데
|
|
51
|
+
* 적으면 파생 부하가 실제보다 낙관적으로 나온다(피크가 깎인 것으로 보인다). 그러니 「도입하면?」을
|
|
52
|
+
* 묻는 것이라면 선언이 아니라 **what-if 가 덮어쓸 일**이다.
|
|
53
|
+
*
|
|
54
|
+
* 용량이 없으면 방전하지 않는다 — 용량을 모르면 「얼마나 버티나」를 답할 수 없고, 버티는 시간을
|
|
55
|
+
* 모른 채 깎으면 무한히 깎을 수 있다고 주장하는 셈이다.
|
|
56
|
+
*/
|
|
57
|
+
/** 축전지 용량(kWh) — SOC 를 에너지로 바꾸는 값. 없으면 방전을 만들지 않는다. */
|
|
58
|
+
capacityKWh: 'storage.capacityKWh',
|
|
59
|
+
/** 이 순수요를 넘으면 방전한다(kW) — 피크 억제 임계. */
|
|
60
|
+
dischargeAboveKW: 'dispatch.dischargeAboveKW',
|
|
61
|
+
/** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
|
|
62
|
+
maxDischargeKW: 'dispatch.maxDischargeKW',
|
|
63
|
+
/** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
|
|
64
|
+
reserveSoc: 'dispatch.reserveSoc'
|
|
43
65
|
};
|
|
44
66
|
export const EMS_TYPES = [
|
|
45
67
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
@@ -185,6 +185,15 @@ export interface WindowedEnergy {
|
|
|
185
185
|
peakKW?: number;
|
|
186
186
|
/** 그 최대수요가 난 구간의 시작 — 「언제였나」를 물으면 답할 수 있어야 한다. */
|
|
187
187
|
peakStartMs?: number;
|
|
188
|
+
/**
|
|
189
|
+
* **그 피크 구간에서 설비가 끌어당긴 최대**(발전·방전 차감 전).
|
|
190
|
+
*
|
|
191
|
+
* `peakKW` 와의 간격이 그 순간에 깎인 양이다(태양광·배터리가 계통에서 덜어낸 몫). 다른 구간의 값과
|
|
192
|
+
* 견주지 않는다 — **같은 구간의 두 수**여야 그 차이가 뜻을 갖는다.
|
|
193
|
+
*
|
|
194
|
+
* 발전·축전을 선언하지 않은 현장에서는 `peakKW` 와 같다.
|
|
195
|
+
*/
|
|
196
|
+
peakGrossKW?: number;
|
|
188
197
|
/**
|
|
189
198
|
* 어떻게 얻었나 — **파생임을 숨기지 않는다.**
|
|
190
199
|
* · `mean-kw` — 구간 평균부하 × 구간 길이. 표본의 평균이므로 **추정**이다.
|
|
@@ -217,6 +226,7 @@ export declare function energyOfWindows(windows: readonly {
|
|
|
217
226
|
endMs: number;
|
|
218
227
|
meanKW?: number;
|
|
219
228
|
maxKW?: number;
|
|
229
|
+
grossMaxKW?: number;
|
|
220
230
|
}[], range?: {
|
|
221
231
|
startMs: number;
|
|
222
232
|
endMs: number;
|
|
@@ -205,6 +205,7 @@ export function energyOfWindows(windows, range) {
|
|
|
205
205
|
let last;
|
|
206
206
|
let peakKW;
|
|
207
207
|
let peakStartMs;
|
|
208
|
+
let peakGrossKW;
|
|
208
209
|
for (const w of windows ?? []) {
|
|
209
210
|
if (!inRange(w))
|
|
210
211
|
continue;
|
|
@@ -216,6 +217,9 @@ export function energyOfWindows(windows, range) {
|
|
|
216
217
|
if (Number.isFinite(max) && (peakKW === undefined || max > peakKW)) {
|
|
217
218
|
peakKW = max;
|
|
218
219
|
peakStartMs = Number(w.startMs);
|
|
220
|
+
/* **그 구간의** 총부하를 함께 집는다 — 다른 구간의 총부하와 견주면 차이가 뜻을 잃는다. */
|
|
221
|
+
const gross = Number(w.grossMaxKW);
|
|
222
|
+
peakGrossKW = Number.isFinite(gross) ? gross : undefined;
|
|
219
223
|
}
|
|
220
224
|
const mean = Number(w.meanKW);
|
|
221
225
|
const hours = (Number(w.endMs) - Number(w.startMs)) / 3_600_000;
|
|
@@ -233,6 +237,7 @@ export function energyOfWindows(windows, range) {
|
|
|
233
237
|
return {
|
|
234
238
|
...(counted > 0 ? { kWh } : {}),
|
|
235
239
|
...(peakKW !== undefined ? { peakKW, peakStartMs } : {}),
|
|
240
|
+
...(peakGrossKW !== undefined ? { peakGrossKW } : {}),
|
|
236
241
|
basis: 'mean-kw',
|
|
237
242
|
counted,
|
|
238
243
|
skipped,
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
|
|
1
|
+
import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
|
|
2
2
|
import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
|
|
3
3
|
import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
|
|
4
4
|
import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
|
|
@@ -325,6 +325,10 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
325
325
|
/** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
|
|
326
326
|
private handlersRef;
|
|
327
327
|
private gens;
|
|
328
|
+
/** 가정한 개입 — 시나리오가 실어 온 것들. `pendingMs` 는 시나리오 시작 기준 절대 클록으로 바뀐다. */
|
|
329
|
+
private interventions;
|
|
330
|
+
/** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
331
|
+
private interventionLog;
|
|
328
332
|
private generating;
|
|
329
333
|
private speed;
|
|
330
334
|
private eventSeq;
|
|
@@ -412,6 +416,18 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
412
416
|
/** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없는 유일한 축이라 스냅샷에서 이어받는다. */
|
|
413
417
|
acked?: string[];
|
|
414
418
|
}, orders?: OrderStatusDelta[]): void;
|
|
419
|
+
/**
|
|
420
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
421
|
+
*
|
|
422
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
423
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
424
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
425
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
426
|
+
*
|
|
427
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
428
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
429
|
+
*/
|
|
430
|
+
setResourceProperty(resourceId: string, propertyId: string, value: string | number): boolean;
|
|
415
431
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
416
432
|
setLocationCapacity(locationId: string, capacity: number): boolean;
|
|
417
433
|
/**
|
|
@@ -451,6 +467,19 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
451
467
|
* rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
|
|
452
468
|
*/
|
|
453
469
|
fork(tenantId?: string): this;
|
|
470
|
+
/**
|
|
471
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
472
|
+
*
|
|
473
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
474
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
475
|
+
*/
|
|
476
|
+
private applyInterventions;
|
|
477
|
+
/**
|
|
478
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
479
|
+
*
|
|
480
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
481
|
+
*/
|
|
482
|
+
interventionOutcomes(): InterventionOutcome[];
|
|
454
483
|
/**
|
|
455
484
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
|
456
485
|
*
|
package/dist/flow-engine.js
CHANGED
|
@@ -286,6 +286,10 @@ export class FlowEngine {
|
|
|
286
286
|
return this.handlers;
|
|
287
287
|
}
|
|
288
288
|
gens = [];
|
|
289
|
+
/** 가정한 개입 — 시나리오가 실어 온 것들. `pendingMs` 는 시나리오 시작 기준 절대 클록으로 바뀐다. */
|
|
290
|
+
interventions = [];
|
|
291
|
+
/** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
292
|
+
interventionLog = [];
|
|
289
293
|
generating = false;
|
|
290
294
|
speed = 1;
|
|
291
295
|
eventSeq = 0;
|
|
@@ -682,6 +686,31 @@ export class FlowEngine {
|
|
|
682
686
|
'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
|
|
683
687
|
}
|
|
684
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
691
|
+
*
|
|
692
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
693
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
694
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
695
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
696
|
+
*
|
|
697
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
698
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
699
|
+
*/
|
|
700
|
+
setResourceProperty(resourceId, propertyId, value) {
|
|
701
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
702
|
+
if (e.id !== resourceId)
|
|
703
|
+
continue;
|
|
704
|
+
const props = (e.properties ??= []);
|
|
705
|
+
const found = props.find((p) => p?.id === propertyId);
|
|
706
|
+
if (found)
|
|
707
|
+
found.value = String(value);
|
|
708
|
+
else
|
|
709
|
+
props.push({ id: propertyId, value: String(value) });
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
685
714
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
686
715
|
setLocationCapacity(locationId, capacity) {
|
|
687
716
|
const n = this.locations.get(locationId);
|
|
@@ -843,6 +872,9 @@ export class FlowEngine {
|
|
|
843
872
|
this.rng = mulberry32((def.seed ?? 1) >>> 0);
|
|
844
873
|
this.speed = def.speed ?? 1;
|
|
845
874
|
this.gens = def.generators.map(spec => ({ spec, nextMs: 0 }));
|
|
875
|
+
/* 개입은 **시작할 때** 시각이 정해진다(경과 기준) — 여기서는 받아만 둔다. */
|
|
876
|
+
this.interventions = (def.interventions ?? []).map(spec => ({ spec, dueMs: 0, done: false }));
|
|
877
|
+
this.interventionLog = [];
|
|
846
878
|
},
|
|
847
879
|
start: () => {
|
|
848
880
|
if (this.generating)
|
|
@@ -850,14 +882,25 @@ export class FlowEngine {
|
|
|
850
882
|
this.generating = true;
|
|
851
883
|
for (const g of this.gens)
|
|
852
884
|
g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
885
|
+
/* 경과를 지금 기준으로 절대화한다 — fork 가 언제 갈라져도 같은 가정을 같은 순서로 겪는다. */
|
|
886
|
+
for (const iv of this.interventions) {
|
|
887
|
+
iv.dueMs = this.clockMs + Math.max(0, Number(iv.spec.atMs) || 0);
|
|
888
|
+
iv.done = false;
|
|
889
|
+
}
|
|
853
890
|
},
|
|
854
891
|
pause: () => { this.generating = false; },
|
|
855
|
-
reset: () => { this.generating = false; this.gens = []; },
|
|
892
|
+
reset: () => { this.generating = false; this.gens = []; this.interventions = []; this.interventionLog = []; },
|
|
856
893
|
setSpeed: (f) => { this.speed = f; }
|
|
857
894
|
};
|
|
858
895
|
tick(dtMs) {
|
|
859
896
|
const dt = dtMs * this.speed;
|
|
860
897
|
this.clockMs += dt;
|
|
898
|
+
/*
|
|
899
|
+
* 개입을 **자극보다 먼저** 적용한다 — 그 틱에 생기는 일이 바뀐 조건을 보게 해야 한다.
|
|
900
|
+
* 나중에 적용하면 「세운 설비에 일감을 배정한 뒤 세우는」 한 틱이 생긴다.
|
|
901
|
+
*/
|
|
902
|
+
if (this.generating)
|
|
903
|
+
this.applyInterventions();
|
|
861
904
|
if (this.generating)
|
|
862
905
|
this.generate();
|
|
863
906
|
this.processFailures(dt);
|
|
@@ -1039,6 +1082,41 @@ export class FlowEngine {
|
|
|
1039
1082
|
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
1040
1083
|
return clone;
|
|
1041
1084
|
}
|
|
1085
|
+
/**
|
|
1086
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
1087
|
+
*
|
|
1088
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
1089
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
1090
|
+
*/
|
|
1091
|
+
applyInterventions() {
|
|
1092
|
+
for (const iv of this.interventions) {
|
|
1093
|
+
if (iv.done || this.clockMs < iv.dueMs)
|
|
1094
|
+
continue;
|
|
1095
|
+
iv.done = true;
|
|
1096
|
+
/* 커맨드 계약 그대로 만든다 — 개입은 새 종류의 사건이 아니라 **행동**이다. */
|
|
1097
|
+
const ack = this.dispatch({
|
|
1098
|
+
commandId: `iv-${this.tenantId}-${iv.spec.kind}-${iv.dueMs}`,
|
|
1099
|
+
type: iv.spec.kind,
|
|
1100
|
+
tenantId: this.tenantId,
|
|
1101
|
+
args: iv.spec.args ?? {}
|
|
1102
|
+
});
|
|
1103
|
+
this.interventionLog.push({
|
|
1104
|
+
atMs: iv.spec.atMs,
|
|
1105
|
+
kind: iv.spec.kind,
|
|
1106
|
+
...(iv.spec.args ? { args: iv.spec.args } : {}),
|
|
1107
|
+
applied: ack.accepted === true,
|
|
1108
|
+
...(ack.accepted === true ? {} : { refusedCode: ack.errorCode ?? 'refused' })
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
1114
|
+
*
|
|
1115
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
1116
|
+
*/
|
|
1117
|
+
interventionOutcomes() {
|
|
1118
|
+
return this.interventionLog.map(o => ({ ...o }));
|
|
1119
|
+
}
|
|
1042
1120
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
1043
1121
|
/**
|
|
1044
1122
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -1703,7 +1703,29 @@ var EMS_PROPERTY = {
|
|
|
1703
1703
|
/** 사용량 단가 — 1kWh 당. */
|
|
1704
1704
|
energyChargePerKWh: "tariff.energyChargePerKWh",
|
|
1705
1705
|
/** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
|
|
1706
|
-
currency: "tariff.currency"
|
|
1706
|
+
currency: "tariff.currency",
|
|
1707
|
+
/*
|
|
1708
|
+
* ── 축전지의 용량과 방전 정책 (2026-08-17) ─────────────────────────────────
|
|
1709
|
+
*
|
|
1710
|
+
* 「배터리로 피크를 깎는다」를 시뮬이 보이려면 방전을 만들어야 하고, 그것은 **선언에서** 나와야 한다.
|
|
1711
|
+
* 임계·최대율·예비를 우리가 정하면 그 수가 어디서 왔는지 아무도 설명할 수 없다.
|
|
1712
|
+
*
|
|
1713
|
+
* ── 선언한다는 것은 「그 자동화가 있다」는 주장이다 ─────────────────────────
|
|
1714
|
+
* 이 정책을 모델에 적으면 **라이브가 아닌 모든 구동이 그 규칙으로 돈다.** 현장에 BESS 제어기가 없는데
|
|
1715
|
+
* 적으면 파생 부하가 실제보다 낙관적으로 나온다(피크가 깎인 것으로 보인다). 그러니 「도입하면?」을
|
|
1716
|
+
* 묻는 것이라면 선언이 아니라 **what-if 가 덮어쓸 일**이다.
|
|
1717
|
+
*
|
|
1718
|
+
* 용량이 없으면 방전하지 않는다 — 용량을 모르면 「얼마나 버티나」를 답할 수 없고, 버티는 시간을
|
|
1719
|
+
* 모른 채 깎으면 무한히 깎을 수 있다고 주장하는 셈이다.
|
|
1720
|
+
*/
|
|
1721
|
+
/** 축전지 용량(kWh) — SOC 를 에너지로 바꾸는 값. 없으면 방전을 만들지 않는다. */
|
|
1722
|
+
capacityKWh: "storage.capacityKWh",
|
|
1723
|
+
/** 이 순수요를 넘으면 방전한다(kW) — 피크 억제 임계. */
|
|
1724
|
+
dischargeAboveKW: "dispatch.dischargeAboveKW",
|
|
1725
|
+
/** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
|
|
1726
|
+
maxDischargeKW: "dispatch.maxDischargeKW",
|
|
1727
|
+
/** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
|
|
1728
|
+
reserveSoc: "dispatch.reserveSoc"
|
|
1707
1729
|
};
|
|
1708
1730
|
var EMS_TYPES = [
|
|
1709
1731
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
@@ -2970,6 +2992,10 @@ var FlowEngine = class {
|
|
|
2970
2992
|
return this.handlers;
|
|
2971
2993
|
}
|
|
2972
2994
|
gens = [];
|
|
2995
|
+
/** 가정한 개입 — 시나리오가 실어 온 것들. `pendingMs` 는 시나리오 시작 기준 절대 클록으로 바뀐다. */
|
|
2996
|
+
interventions = [];
|
|
2997
|
+
/** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
2998
|
+
interventionLog = [];
|
|
2973
2999
|
generating = false;
|
|
2974
3000
|
speed = 1;
|
|
2975
3001
|
eventSeq = 0;
|
|
@@ -3334,6 +3360,28 @@ var FlowEngine = class {
|
|
|
3334
3360
|
);
|
|
3335
3361
|
}
|
|
3336
3362
|
}
|
|
3363
|
+
/**
|
|
3364
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
3365
|
+
*
|
|
3366
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
3367
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
3368
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
3369
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
3370
|
+
*
|
|
3371
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
3372
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
3373
|
+
*/
|
|
3374
|
+
setResourceProperty(resourceId, propertyId, value) {
|
|
3375
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
3376
|
+
if (e.id !== resourceId) continue;
|
|
3377
|
+
const props = e.properties ??= [];
|
|
3378
|
+
const found = props.find((p) => p?.id === propertyId);
|
|
3379
|
+
if (found) found.value = String(value);
|
|
3380
|
+
else props.push({ id: propertyId, value: String(value) });
|
|
3381
|
+
return true;
|
|
3382
|
+
}
|
|
3383
|
+
return false;
|
|
3384
|
+
}
|
|
3337
3385
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
3338
3386
|
setLocationCapacity(locationId, capacity) {
|
|
3339
3387
|
const n = this.locations.get(locationId);
|
|
@@ -3473,11 +3521,17 @@ var FlowEngine = class {
|
|
|
3473
3521
|
this.rng = mulberry32((def.seed ?? 1) >>> 0);
|
|
3474
3522
|
this.speed = def.speed ?? 1;
|
|
3475
3523
|
this.gens = def.generators.map((spec) => ({ spec, nextMs: 0 }));
|
|
3524
|
+
this.interventions = (def.interventions ?? []).map((spec) => ({ spec, dueMs: 0, done: false }));
|
|
3525
|
+
this.interventionLog = [];
|
|
3476
3526
|
},
|
|
3477
3527
|
start: () => {
|
|
3478
3528
|
if (this.generating) return;
|
|
3479
3529
|
this.generating = true;
|
|
3480
3530
|
for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
3531
|
+
for (const iv of this.interventions) {
|
|
3532
|
+
iv.dueMs = this.clockMs + Math.max(0, Number(iv.spec.atMs) || 0);
|
|
3533
|
+
iv.done = false;
|
|
3534
|
+
}
|
|
3481
3535
|
},
|
|
3482
3536
|
pause: () => {
|
|
3483
3537
|
this.generating = false;
|
|
@@ -3485,6 +3539,8 @@ var FlowEngine = class {
|
|
|
3485
3539
|
reset: () => {
|
|
3486
3540
|
this.generating = false;
|
|
3487
3541
|
this.gens = [];
|
|
3542
|
+
this.interventions = [];
|
|
3543
|
+
this.interventionLog = [];
|
|
3488
3544
|
},
|
|
3489
3545
|
setSpeed: (f) => {
|
|
3490
3546
|
this.speed = f;
|
|
@@ -3493,6 +3549,7 @@ var FlowEngine = class {
|
|
|
3493
3549
|
tick(dtMs) {
|
|
3494
3550
|
const dt = dtMs * this.speed;
|
|
3495
3551
|
this.clockMs += dt;
|
|
3552
|
+
if (this.generating) this.applyInterventions();
|
|
3496
3553
|
if (this.generating) this.generate();
|
|
3497
3554
|
this.processFailures(dt);
|
|
3498
3555
|
this.processOrders();
|
|
@@ -3654,6 +3711,39 @@ var FlowEngine = class {
|
|
|
3654
3711
|
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
3655
3712
|
return clone;
|
|
3656
3713
|
}
|
|
3714
|
+
/**
|
|
3715
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
3716
|
+
*
|
|
3717
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
3718
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
3719
|
+
*/
|
|
3720
|
+
applyInterventions() {
|
|
3721
|
+
for (const iv of this.interventions) {
|
|
3722
|
+
if (iv.done || this.clockMs < iv.dueMs) continue;
|
|
3723
|
+
iv.done = true;
|
|
3724
|
+
const ack = this.dispatch({
|
|
3725
|
+
commandId: `iv-${this.tenantId}-${iv.spec.kind}-${iv.dueMs}`,
|
|
3726
|
+
type: iv.spec.kind,
|
|
3727
|
+
tenantId: this.tenantId,
|
|
3728
|
+
args: iv.spec.args ?? {}
|
|
3729
|
+
});
|
|
3730
|
+
this.interventionLog.push({
|
|
3731
|
+
atMs: iv.spec.atMs,
|
|
3732
|
+
kind: iv.spec.kind,
|
|
3733
|
+
...iv.spec.args ? { args: iv.spec.args } : {},
|
|
3734
|
+
applied: ack.accepted === true,
|
|
3735
|
+
...ack.accepted === true ? {} : { refusedCode: ack.errorCode ?? "refused" }
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
3741
|
+
*
|
|
3742
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
3743
|
+
*/
|
|
3744
|
+
interventionOutcomes() {
|
|
3745
|
+
return this.interventionLog.map((o) => ({ ...o }));
|
|
3746
|
+
}
|
|
3657
3747
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
3658
3748
|
/**
|
|
3659
3749
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
|
@@ -6019,6 +6109,9 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6019
6109
|
startMs: w.startMs,
|
|
6020
6110
|
endMs: w.endMs,
|
|
6021
6111
|
...w.maxKW !== void 0 ? { maxKW: w.maxKW } : {},
|
|
6112
|
+
/* 총부하도 사실로 남긴다 — 이것이 없으면 저널을 읽는 쪽은 「무엇이 깎였나」를 되짚을 수 없다
|
|
6113
|
+
(요금은 순수요로 매겨지지만, 그 수요를 만든 것은 설비 부하다). */
|
|
6114
|
+
...w.grossMaxKW !== void 0 ? { grossMaxKW: w.grossMaxKW } : {},
|
|
6022
6115
|
...w.meanKW !== void 0 ? { meanKW: w.meanKW } : {},
|
|
6023
6116
|
samples: w.samples,
|
|
6024
6117
|
...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
|
|
@@ -6123,7 +6216,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6123
6216
|
* ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
|
|
6124
6217
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
6125
6218
|
*/
|
|
6126
|
-
deriveLoad(atMs) {
|
|
6219
|
+
deriveLoad(atMs, dtMs) {
|
|
6127
6220
|
if (this.observing) return;
|
|
6128
6221
|
let total = 0;
|
|
6129
6222
|
let counted = 0;
|
|
@@ -6144,16 +6237,102 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6144
6237
|
}
|
|
6145
6238
|
}
|
|
6146
6239
|
if (!counted) return;
|
|
6240
|
+
let generated = 0;
|
|
6241
|
+
let discharged = 0;
|
|
6242
|
+
let charged = 0;
|
|
6243
|
+
for (const m of this.equipment.values()) {
|
|
6244
|
+
const g = Number(m.generatedKW);
|
|
6245
|
+
if (Number.isFinite(g) && g > 0) generated += g;
|
|
6246
|
+
const c = Number(m.chargeKW);
|
|
6247
|
+
if (Number.isFinite(c) && c > 0) charged += c;
|
|
6248
|
+
if (this.hasDispatchPolicy(m.id)) continue;
|
|
6249
|
+
const d = Number(m.dischargeKW);
|
|
6250
|
+
if (Number.isFinite(d) && d > 0) discharged += d;
|
|
6251
|
+
}
|
|
6252
|
+
const gross = total + charged;
|
|
6253
|
+
const dispatched = this.dispatchStorage(Math.max(0, gross - generated - discharged), atMs, dtMs);
|
|
6254
|
+
const net = Math.max(0, gross - generated - discharged - dispatched);
|
|
6147
6255
|
this.closeDue(atMs);
|
|
6148
6256
|
this.openWindow(atMs);
|
|
6149
6257
|
const w = this.open;
|
|
6150
6258
|
w.derived = true;
|
|
6151
|
-
if (w.maxKW === void 0 ||
|
|
6259
|
+
if (w.maxKW === void 0 || net > w.maxKW) w.maxKW = net;
|
|
6260
|
+
if (w.grossMaxKW === void 0 || gross > w.grossMaxKW) w.grossMaxKW = gross;
|
|
6152
6261
|
w.samples++;
|
|
6153
|
-
w.meanKW = w.meanKW === void 0 ?
|
|
6262
|
+
w.meanKW = w.meanKW === void 0 ? net : (w.meanKW * (w.samples - 1) + net) / w.samples;
|
|
6154
6263
|
this.revision++;
|
|
6155
6264
|
this.judgeOpenWindow(atMs);
|
|
6156
6265
|
}
|
|
6266
|
+
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
6267
|
+
hasDispatchPolicy(equipmentId) {
|
|
6268
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6269
|
+
if (e.id !== equipmentId) continue;
|
|
6270
|
+
return this.numberProperty(e, EMS_PROPERTY.capacityKWh) !== void 0 && this.numberProperty(e, EMS_PROPERTY.dischargeAboveKW) !== void 0 && this.numberProperty(e, EMS_PROPERTY.maxDischargeKW) !== void 0;
|
|
6271
|
+
}
|
|
6272
|
+
return false;
|
|
6273
|
+
}
|
|
6274
|
+
/**
|
|
6275
|
+
* 선언된 정책으로 축전지를 방전시킨다 — **선언이 없으면 아무것도 하지 않는다.**
|
|
6276
|
+
*
|
|
6277
|
+
* 돌려주는 값은 이번 틱에 계통에서 덜어낸 kW 다. 부작용으로 그 설비의 `dischargeKW`·`soc` 를 고치고
|
|
6278
|
+
* 사실(`energy.equipment`)을 낸다 — 저널을 읽는 쪽이 「배터리가 무엇을 했나」를 되짚을 수 있어야 한다.
|
|
6279
|
+
*
|
|
6280
|
+
* ── 무엇을 하지 않나 ──────────────────────────────────────────────────────
|
|
6281
|
+
* · 충전 계획을 만들지 않는다(언제 채울지는 요금 시간대의 일이고, 그 모델은 아직 없다).
|
|
6282
|
+
* · 용량·임계·최대율이 하나라도 없으면 방전하지 않는다 — 짐작한 수로 피크를 깎지 않는다.
|
|
6283
|
+
* · 미러 트윈에서는 불리지 않는다(호출자가 이미 관측 모드를 걸러낸다).
|
|
6284
|
+
*/
|
|
6285
|
+
dispatchStorage(demandBeforeStorageKW, atMs, dtMs) {
|
|
6286
|
+
let dispatched = 0;
|
|
6287
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6288
|
+
const capacity = this.numberProperty(e, EMS_PROPERTY.capacityKWh);
|
|
6289
|
+
const above = this.numberProperty(e, EMS_PROPERTY.dischargeAboveKW);
|
|
6290
|
+
const maxRate = this.numberProperty(e, EMS_PROPERTY.maxDischargeKW);
|
|
6291
|
+
if (capacity === void 0 || above === void 0 || maxRate === void 0) continue;
|
|
6292
|
+
if (!(capacity > 0) || !(maxRate > 0)) continue;
|
|
6293
|
+
const state = this.equipment.get(e.id);
|
|
6294
|
+
if (!state) continue;
|
|
6295
|
+
const over = demandBeforeStorageKW - dispatched - above;
|
|
6296
|
+
if (over <= 0) {
|
|
6297
|
+
if (Number(state.dischargeKW) > 0) {
|
|
6298
|
+
state.dischargeKW = 0;
|
|
6299
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, ...Number.isFinite(Number(state.soc)) ? { soc: Number(state.soc) } : {} });
|
|
6300
|
+
}
|
|
6301
|
+
continue;
|
|
6302
|
+
}
|
|
6303
|
+
const reserve = this.numberProperty(e, EMS_PROPERTY.reserveSoc) ?? 0;
|
|
6304
|
+
const soc = Number(state.soc);
|
|
6305
|
+
if (!Number.isFinite(soc)) continue;
|
|
6306
|
+
const usableKWh = (soc - reserve) / 100 * capacity;
|
|
6307
|
+
if (!(usableKWh > 0)) {
|
|
6308
|
+
if (Number(state.dischargeKW) > 0) {
|
|
6309
|
+
state.dischargeKW = 0;
|
|
6310
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, soc });
|
|
6311
|
+
}
|
|
6312
|
+
continue;
|
|
6313
|
+
}
|
|
6314
|
+
const dtHours = dtMs / 36e5;
|
|
6315
|
+
const rateCap = dtHours > 0 ? usableKWh / dtHours : maxRate;
|
|
6316
|
+
const kW = Math.min(maxRate, over, rateCap);
|
|
6317
|
+
if (!(kW > 0)) continue;
|
|
6318
|
+
const drained = dtHours > 0 ? kW * dtHours / capacity * 100 : 0;
|
|
6319
|
+
const nextSoc = Math.max(reserve, soc - drained);
|
|
6320
|
+
state.dischargeKW = kW;
|
|
6321
|
+
state.soc = nextSoc;
|
|
6322
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: kW, soc: nextSoc });
|
|
6323
|
+
dispatched += kW;
|
|
6324
|
+
}
|
|
6325
|
+
return dispatched;
|
|
6326
|
+
}
|
|
6327
|
+
/**
|
|
6328
|
+
* 파생된 설비 에너지 상태를 사실로 낸다 — **만든 값임을 밝힌다.**
|
|
6329
|
+
*
|
|
6330
|
+
* 계측으로 들어온 것과 같은 자리에 두면서 종류를 적지 않으면, 저널을 읽는 쪽이 우리가 계산한 방전을
|
|
6331
|
+
* 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
|
|
6332
|
+
*/
|
|
6333
|
+
emitEnergyEquipment(equipmentId, atMs, fields) {
|
|
6334
|
+
this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
|
|
6335
|
+
}
|
|
6157
6336
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
6158
6337
|
numberProperty(resource, id) {
|
|
6159
6338
|
for (const p of resource?.properties ?? []) {
|
|
@@ -6166,7 +6345,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6166
6345
|
tick(dtMs) {
|
|
6167
6346
|
super.tick(dtMs);
|
|
6168
6347
|
const at = this.nowMs();
|
|
6169
|
-
this.deriveLoad(at);
|
|
6348
|
+
this.deriveLoad(at, dtMs);
|
|
6170
6349
|
this.closeDue(at);
|
|
6171
6350
|
}
|
|
6172
6351
|
getSnapshot() {
|
|
@@ -6461,12 +6640,15 @@ function energyOfWindows(windows, range) {
|
|
|
6461
6640
|
let last;
|
|
6462
6641
|
let peakKW;
|
|
6463
6642
|
let peakStartMs;
|
|
6643
|
+
let peakGrossKW;
|
|
6464
6644
|
for (const w of windows ?? []) {
|
|
6465
6645
|
if (!inRange(w)) continue;
|
|
6466
6646
|
const max = Number(w.maxKW);
|
|
6467
6647
|
if (Number.isFinite(max) && (peakKW === void 0 || max > peakKW)) {
|
|
6468
6648
|
peakKW = max;
|
|
6469
6649
|
peakStartMs = Number(w.startMs);
|
|
6650
|
+
const gross = Number(w.grossMaxKW);
|
|
6651
|
+
peakGrossKW = Number.isFinite(gross) ? gross : void 0;
|
|
6470
6652
|
}
|
|
6471
6653
|
const mean = Number(w.meanKW);
|
|
6472
6654
|
const hours = (Number(w.endMs) - Number(w.startMs)) / 36e5;
|
|
@@ -6482,6 +6664,7 @@ function energyOfWindows(windows, range) {
|
|
|
6482
6664
|
return {
|
|
6483
6665
|
...counted > 0 ? { kWh } : {},
|
|
6484
6666
|
...peakKW !== void 0 ? { peakKW, peakStartMs } : {},
|
|
6667
|
+
...peakGrossKW !== void 0 ? { peakGrossKW } : {},
|
|
6485
6668
|
basis: "mean-kw",
|
|
6486
6669
|
counted,
|
|
6487
6670
|
skipped,
|
package/package.json
CHANGED