@operato/twin-kernel 0.7.15 → 0.7.17
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 -0
- package/dist/ems-kernel.d.ts +21 -0
- package/dist/ems-kernel.js +120 -6
- package/dist/ems-profile.d.ts +18 -0
- package/dist/ems-profile.js +33 -1
- package/dist/flow-engine.d.ts +39 -1
- package/dist/flow-engine.js +98 -1
- package/dist-cjs/index.cjs +198 -6
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -1433,6 +1433,60 @@ export interface ScenarioDef {
|
|
|
1433
1433
|
speed?: number;
|
|
1434
1434
|
horizon?: number;
|
|
1435
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
|
+
*
|
|
1456
|
+
* 개입(`interventions`)이 시각을 가진 **행동**이라면 이쪽은 시각이 없는 **모델의 변주**다. 둘을 한
|
|
1457
|
+
* 자리에 두지 않는 이유: 행동은 저널에 남을 사실이 되고, 변주는 「그런 현장이었다면」이라는 가정이다.
|
|
1458
|
+
*
|
|
1459
|
+
* 시나리오와 함께 다니므로 fork 가 자기완결이다 — 같은 선언 하나로 예측·백테스트·AI 가 같은 미래를
|
|
1460
|
+
* 재생한다. 다만 **실행 중 트윈에 이 시나리오를 걸면 그 트윈의 선언이 실제로 바뀐다**: 그것은
|
|
1461
|
+
* 「우리 현장에 그 자동화가 있다」는 주장이므로, 무엇을 덮어썼는지 소비처가 읽을 수 있어야 한다
|
|
1462
|
+
* (`declarationOverrides()`).
|
|
1463
|
+
*/
|
|
1464
|
+
overrides?: ScenarioOverride[];
|
|
1465
|
+
}
|
|
1466
|
+
/** 가정한 선언 하나 — 어느 자원의 어느 속성을 무엇으로. */
|
|
1467
|
+
export interface ScenarioOverride {
|
|
1468
|
+
resourceId: string;
|
|
1469
|
+
propertyId: string;
|
|
1470
|
+
value: string | number;
|
|
1471
|
+
/** 그 자원이 없었으면 false — 조용히 성공한 척하지 않는다. */
|
|
1472
|
+
applied?: boolean;
|
|
1473
|
+
}
|
|
1474
|
+
/** 가정한 개입 하나 — 시각 + 행동. */
|
|
1475
|
+
export interface ScenarioIntervention {
|
|
1476
|
+
/** 시나리오 시작부터의 경과(ms). 0 이면 시작하는 순간. */
|
|
1477
|
+
atMs: number;
|
|
1478
|
+
/** 커맨드 이름(`CMD` 의 값) — 예: `resource.hold`. */
|
|
1479
|
+
kind: string;
|
|
1480
|
+
args?: Record<string, unknown>;
|
|
1481
|
+
}
|
|
1482
|
+
/** 개입이 어떻게 됐나 — 걸린 것과 거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
1483
|
+
export interface InterventionOutcome {
|
|
1484
|
+
atMs: number;
|
|
1485
|
+
kind: string;
|
|
1486
|
+
args?: Record<string, unknown>;
|
|
1487
|
+
applied: boolean;
|
|
1488
|
+
/** 거절 이유(커맨드가 낸 코드). 걸렸으면 없다. */
|
|
1489
|
+
refusedCode?: string;
|
|
1436
1490
|
}
|
|
1437
1491
|
export interface ScenarioControl {
|
|
1438
1492
|
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
|
@@ -423,7 +423,7 @@ export class EmsKernel extends FlowEngine {
|
|
|
423
423
|
* ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
|
|
424
424
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
425
425
|
*/
|
|
426
|
-
deriveLoad(atMs) {
|
|
426
|
+
deriveLoad(atMs, dtMs) {
|
|
427
427
|
if (this.observing)
|
|
428
428
|
return; // 미러 — 계측이 진실이다
|
|
429
429
|
let total = 0;
|
|
@@ -477,15 +477,34 @@ export class EmsKernel extends FlowEngine {
|
|
|
477
477
|
const g = Number(m.generatedKW);
|
|
478
478
|
if (Number.isFinite(g) && g > 0)
|
|
479
479
|
generated += g;
|
|
480
|
-
const d = Number(m.dischargeKW);
|
|
481
|
-
if (Number.isFinite(d) && d > 0)
|
|
482
|
-
discharged += d;
|
|
483
480
|
const c = Number(m.chargeKW);
|
|
484
481
|
if (Number.isFinite(c) && c > 0)
|
|
485
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;
|
|
486
497
|
}
|
|
487
498
|
const gross = total + charged;
|
|
488
|
-
|
|
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);
|
|
489
508
|
this.closeDue(atMs);
|
|
490
509
|
this.openWindow(atMs);
|
|
491
510
|
const w = this.open;
|
|
@@ -500,6 +519,100 @@ export class EmsKernel extends FlowEngine {
|
|
|
500
519
|
this.revision++;
|
|
501
520
|
this.judgeOpenWindow(atMs);
|
|
502
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
|
+
/*
|
|
558
|
+
* 시뮬의 출발 SOC — 선언에서 한 번 심는다(계측이 들어오면 그것이 이긴다).
|
|
559
|
+
* 이것이 없던 동안, 정책을 선언해도 「SOC 를 모른다」에 걸려 배터리가 아무 일도 하지 못했다.
|
|
560
|
+
*/
|
|
561
|
+
if (!Number.isFinite(Number(state.soc))) {
|
|
562
|
+
const seed = this.numberProperty(e, EMS_PROPERTY.initialSoc);
|
|
563
|
+
if (seed !== undefined)
|
|
564
|
+
state.soc = seed;
|
|
565
|
+
}
|
|
566
|
+
const over = demandBeforeStorageKW - dispatched - above;
|
|
567
|
+
if (over <= 0) {
|
|
568
|
+
/* 임계 아래 — 쓸 이유가 없다. 낡은 방전값을 남기지 않는다(상태가 지난 틱을 말하게 된다). */
|
|
569
|
+
if (Number(state.dischargeKW) > 0) {
|
|
570
|
+
state.dischargeKW = 0;
|
|
571
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, ...(Number.isFinite(Number(state.soc)) ? { soc: Number(state.soc) } : {}) });
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const reserve = this.numberProperty(e, EMS_PROPERTY.reserveSoc) ?? 0;
|
|
576
|
+
/* SOC 를 모르면 방전하지 않는다 — 남은 에너지를 모른 채 쓰면 얼마나 버티는지 지어내는 것이다. */
|
|
577
|
+
const soc = Number(state.soc);
|
|
578
|
+
if (!Number.isFinite(soc))
|
|
579
|
+
continue;
|
|
580
|
+
const usableKWh = ((soc - reserve) / 100) * capacity;
|
|
581
|
+
if (!(usableKWh > 0)) {
|
|
582
|
+
/* 다 썼다 — 방전을 멈추고 그 사실을 남긴다(피크가 다시 올라오는 것이 사실이다). */
|
|
583
|
+
if (Number(state.dischargeKW) > 0) {
|
|
584
|
+
state.dischargeKW = 0;
|
|
585
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, soc });
|
|
586
|
+
}
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
/*
|
|
590
|
+
* 이 틱에 낼 수 있는 kW — 남은 에너지를 이 틱 길이로 나눈 값이 상한이다. 틱이 짧으면 이 상한은
|
|
591
|
+
* 아주 크므로 실제로 묶는 것은 **누적**이다(SOC 가 줄면서 곧 위 검사에 걸린다).
|
|
592
|
+
*/
|
|
593
|
+
const dtHours = dtMs / 3_600_000;
|
|
594
|
+
const rateCap = dtHours > 0 ? usableKWh / dtHours : maxRate;
|
|
595
|
+
const kW = Math.min(maxRate, over, rateCap);
|
|
596
|
+
if (!(kW > 0))
|
|
597
|
+
continue;
|
|
598
|
+
const drained = dtHours > 0 ? ((kW * dtHours) / capacity) * 100 : 0;
|
|
599
|
+
const nextSoc = Math.max(reserve, soc - drained);
|
|
600
|
+
state.dischargeKW = kW;
|
|
601
|
+
state.soc = nextSoc;
|
|
602
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: kW, soc: nextSoc });
|
|
603
|
+
dispatched += kW;
|
|
604
|
+
}
|
|
605
|
+
return dispatched;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* 파생된 설비 에너지 상태를 사실로 낸다 — **만든 값임을 밝힌다.**
|
|
609
|
+
*
|
|
610
|
+
* 계측으로 들어온 것과 같은 자리에 두면서 종류를 적지 않으면, 저널을 읽는 쪽이 우리가 계산한 방전을
|
|
611
|
+
* 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
|
|
612
|
+
*/
|
|
613
|
+
emitEnergyEquipment(equipmentId, atMs, fields) {
|
|
614
|
+
this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
|
|
615
|
+
}
|
|
503
616
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
504
617
|
numberProperty(resource, id) {
|
|
505
618
|
for (const p of resource?.properties ?? []) {
|
|
@@ -523,7 +636,8 @@ export class EmsKernel extends FlowEngine {
|
|
|
523
636
|
* 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
|
|
524
637
|
*/
|
|
525
638
|
const at = this.nowMs();
|
|
526
|
-
|
|
639
|
+
/* 틱 길이를 함께 넘긴다 — 방전량을 에너지로 바꿀 때 필요하다(필드로 숨기면 어디서 온 값인지 흐려진다). */
|
|
640
|
+
this.deriveLoad(at, dtMs);
|
|
527
641
|
this.closeDue(at);
|
|
528
642
|
}
|
|
529
643
|
getSnapshot() {
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -22,5 +22,23 @@ 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";
|
|
33
|
+
/**
|
|
34
|
+
* 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
|
|
35
|
+
*
|
|
36
|
+
* 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
|
|
37
|
+
* 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
|
|
38
|
+
* 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
|
|
39
|
+
*
|
|
40
|
+
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이깁니다.
|
|
41
|
+
*/
|
|
42
|
+
readonly initialSoc: "storage.initialSoc";
|
|
25
43
|
};
|
|
26
44
|
export declare const EMS_TYPES: TwinTypeInfo[];
|
package/dist/ems-profile.js
CHANGED
|
@@ -39,7 +39,39 @@ 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',
|
|
65
|
+
/**
|
|
66
|
+
* 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
|
|
67
|
+
*
|
|
68
|
+
* 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
|
|
69
|
+
* 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
|
|
70
|
+
* 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
|
|
71
|
+
*
|
|
72
|
+
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이깁니다.
|
|
73
|
+
*/
|
|
74
|
+
initialSoc: 'storage.initialSoc'
|
|
43
75
|
};
|
|
44
76
|
export const EMS_TYPES = [
|
|
45
77
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
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, ScenarioOverride, 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,12 @@ 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;
|
|
332
|
+
/** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
|
|
333
|
+
private overrideLog;
|
|
328
334
|
private generating;
|
|
329
335
|
private speed;
|
|
330
336
|
private eventSeq;
|
|
@@ -412,6 +418,18 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
412
418
|
/** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없는 유일한 축이라 스냅샷에서 이어받는다. */
|
|
413
419
|
acked?: string[];
|
|
414
420
|
}, orders?: OrderStatusDelta[]): void;
|
|
421
|
+
/**
|
|
422
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
423
|
+
*
|
|
424
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
425
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
426
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
427
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
428
|
+
*
|
|
429
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
430
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
431
|
+
*/
|
|
432
|
+
setResourceProperty(resourceId: string, propertyId: string, value: string | number): boolean;
|
|
415
433
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
416
434
|
setLocationCapacity(locationId: string, capacity: number): boolean;
|
|
417
435
|
/**
|
|
@@ -451,6 +469,26 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
451
469
|
* rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
|
|
452
470
|
*/
|
|
453
471
|
fork(tenantId?: string): this;
|
|
472
|
+
/**
|
|
473
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
474
|
+
*
|
|
475
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
476
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
477
|
+
*/
|
|
478
|
+
private applyInterventions;
|
|
479
|
+
/**
|
|
480
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
481
|
+
*
|
|
482
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
483
|
+
*/
|
|
484
|
+
interventionOutcomes(): InterventionOutcome[];
|
|
485
|
+
/**
|
|
486
|
+
* 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
|
|
487
|
+
*
|
|
488
|
+
* 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
|
|
489
|
+
* 화면·AI 가 그 사실을 함께 말해야 한다.
|
|
490
|
+
*/
|
|
491
|
+
declarationOverrides(): ScenarioOverride[];
|
|
454
492
|
/**
|
|
455
493
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
|
456
494
|
*
|
package/dist/flow-engine.js
CHANGED
|
@@ -286,6 +286,12 @@ export class FlowEngine {
|
|
|
286
286
|
return this.handlers;
|
|
287
287
|
}
|
|
288
288
|
gens = [];
|
|
289
|
+
/** 가정한 개입 — 시나리오가 실어 온 것들. `pendingMs` 는 시나리오 시작 기준 절대 클록으로 바뀐다. */
|
|
290
|
+
interventions = [];
|
|
291
|
+
/** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
292
|
+
interventionLog = [];
|
|
293
|
+
/** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
|
|
294
|
+
overrideLog = [];
|
|
289
295
|
generating = false;
|
|
290
296
|
speed = 1;
|
|
291
297
|
eventSeq = 0;
|
|
@@ -682,6 +688,31 @@ export class FlowEngine {
|
|
|
682
688
|
'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
|
|
683
689
|
}
|
|
684
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
693
|
+
*
|
|
694
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
695
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
696
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
697
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
698
|
+
*
|
|
699
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
700
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
701
|
+
*/
|
|
702
|
+
setResourceProperty(resourceId, propertyId, value) {
|
|
703
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
704
|
+
if (e.id !== resourceId)
|
|
705
|
+
continue;
|
|
706
|
+
const props = (e.properties ??= []);
|
|
707
|
+
const found = props.find((p) => p?.id === propertyId);
|
|
708
|
+
if (found)
|
|
709
|
+
found.value = String(value);
|
|
710
|
+
else
|
|
711
|
+
props.push({ id: propertyId, value: String(value) });
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
685
716
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
686
717
|
setLocationCapacity(locationId, capacity) {
|
|
687
718
|
const n = this.locations.get(locationId);
|
|
@@ -843,6 +874,17 @@ export class FlowEngine {
|
|
|
843
874
|
this.rng = mulberry32((def.seed ?? 1) >>> 0);
|
|
844
875
|
this.speed = def.speed ?? 1;
|
|
845
876
|
this.gens = def.generators.map(spec => ({ spec, nextMs: 0 }));
|
|
877
|
+
/* 개입은 **시작할 때** 시각이 정해진다(경과 기준) — 여기서는 받아만 둔다. */
|
|
878
|
+
this.interventions = (def.interventions ?? []).map(spec => ({ spec, dueMs: 0, done: false }));
|
|
879
|
+
this.interventionLog = [];
|
|
880
|
+
/*
|
|
881
|
+
* 가정한 선언은 **지금 곧 걸린다** — 시각이 없는 변주이므로 미룰 것이 없다.
|
|
882
|
+
* 무엇을 덮어썼는지 남긴다: 말하지 않으면 소비처가 what-if 결과를 트윈의 현재 상태로 읽는다.
|
|
883
|
+
*/
|
|
884
|
+
this.overrideLog = (def.overrides ?? []).map(o => ({
|
|
885
|
+
...o,
|
|
886
|
+
applied: this.setResourceProperty(o.resourceId, o.propertyId, o.value)
|
|
887
|
+
}));
|
|
846
888
|
},
|
|
847
889
|
start: () => {
|
|
848
890
|
if (this.generating)
|
|
@@ -850,14 +892,25 @@ export class FlowEngine {
|
|
|
850
892
|
this.generating = true;
|
|
851
893
|
for (const g of this.gens)
|
|
852
894
|
g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
895
|
+
/* 경과를 지금 기준으로 절대화한다 — fork 가 언제 갈라져도 같은 가정을 같은 순서로 겪는다. */
|
|
896
|
+
for (const iv of this.interventions) {
|
|
897
|
+
iv.dueMs = this.clockMs + Math.max(0, Number(iv.spec.atMs) || 0);
|
|
898
|
+
iv.done = false;
|
|
899
|
+
}
|
|
853
900
|
},
|
|
854
901
|
pause: () => { this.generating = false; },
|
|
855
|
-
reset: () => { this.generating = false; this.gens = []; },
|
|
902
|
+
reset: () => { this.generating = false; this.gens = []; this.interventions = []; this.interventionLog = []; this.overrideLog = []; },
|
|
856
903
|
setSpeed: (f) => { this.speed = f; }
|
|
857
904
|
};
|
|
858
905
|
tick(dtMs) {
|
|
859
906
|
const dt = dtMs * this.speed;
|
|
860
907
|
this.clockMs += dt;
|
|
908
|
+
/*
|
|
909
|
+
* 개입을 **자극보다 먼저** 적용한다 — 그 틱에 생기는 일이 바뀐 조건을 보게 해야 한다.
|
|
910
|
+
* 나중에 적용하면 「세운 설비에 일감을 배정한 뒤 세우는」 한 틱이 생긴다.
|
|
911
|
+
*/
|
|
912
|
+
if (this.generating)
|
|
913
|
+
this.applyInterventions();
|
|
861
914
|
if (this.generating)
|
|
862
915
|
this.generate();
|
|
863
916
|
this.processFailures(dt);
|
|
@@ -1039,6 +1092,50 @@ export class FlowEngine {
|
|
|
1039
1092
|
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
1040
1093
|
return clone;
|
|
1041
1094
|
}
|
|
1095
|
+
/**
|
|
1096
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
1097
|
+
*
|
|
1098
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
1099
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
1100
|
+
*/
|
|
1101
|
+
applyInterventions() {
|
|
1102
|
+
for (const iv of this.interventions) {
|
|
1103
|
+
if (iv.done || this.clockMs < iv.dueMs)
|
|
1104
|
+
continue;
|
|
1105
|
+
iv.done = true;
|
|
1106
|
+
/* 커맨드 계약 그대로 만든다 — 개입은 새 종류의 사건이 아니라 **행동**이다. */
|
|
1107
|
+
const ack = this.dispatch({
|
|
1108
|
+
commandId: `iv-${this.tenantId}-${iv.spec.kind}-${iv.dueMs}`,
|
|
1109
|
+
type: iv.spec.kind,
|
|
1110
|
+
tenantId: this.tenantId,
|
|
1111
|
+
args: iv.spec.args ?? {}
|
|
1112
|
+
});
|
|
1113
|
+
this.interventionLog.push({
|
|
1114
|
+
atMs: iv.spec.atMs,
|
|
1115
|
+
kind: iv.spec.kind,
|
|
1116
|
+
...(iv.spec.args ? { args: iv.spec.args } : {}),
|
|
1117
|
+
applied: ack.accepted === true,
|
|
1118
|
+
...(ack.accepted === true ? {} : { refusedCode: ack.errorCode ?? 'refused' })
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
1124
|
+
*
|
|
1125
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
1126
|
+
*/
|
|
1127
|
+
interventionOutcomes() {
|
|
1128
|
+
return this.interventionLog.map(o => ({ ...o }));
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
|
|
1132
|
+
*
|
|
1133
|
+
* 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
|
|
1134
|
+
* 화면·AI 가 그 사실을 함께 말해야 한다.
|
|
1135
|
+
*/
|
|
1136
|
+
declarationOverrides() {
|
|
1137
|
+
return this.overrideLog.map(o => ({ ...o }));
|
|
1138
|
+
}
|
|
1042
1139
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
1043
1140
|
/**
|
|
1044
1141
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -1703,7 +1703,39 @@ 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",
|
|
1729
|
+
/**
|
|
1730
|
+
* 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
|
|
1731
|
+
*
|
|
1732
|
+
* 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
|
|
1733
|
+
* 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
|
|
1734
|
+
* 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
|
|
1735
|
+
*
|
|
1736
|
+
* 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이깁니다.
|
|
1737
|
+
*/
|
|
1738
|
+
initialSoc: "storage.initialSoc"
|
|
1707
1739
|
};
|
|
1708
1740
|
var EMS_TYPES = [
|
|
1709
1741
|
/* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
|
|
@@ -2970,6 +3002,12 @@ var FlowEngine = class {
|
|
|
2970
3002
|
return this.handlers;
|
|
2971
3003
|
}
|
|
2972
3004
|
gens = [];
|
|
3005
|
+
/** 가정한 개입 — 시나리오가 실어 온 것들. `pendingMs` 는 시나리오 시작 기준 절대 클록으로 바뀐다. */
|
|
3006
|
+
interventions = [];
|
|
3007
|
+
/** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
|
|
3008
|
+
interventionLog = [];
|
|
3009
|
+
/** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
|
|
3010
|
+
overrideLog = [];
|
|
2973
3011
|
generating = false;
|
|
2974
3012
|
speed = 1;
|
|
2975
3013
|
eventSeq = 0;
|
|
@@ -3334,6 +3372,28 @@ var FlowEngine = class {
|
|
|
3334
3372
|
);
|
|
3335
3373
|
}
|
|
3336
3374
|
}
|
|
3375
|
+
/**
|
|
3376
|
+
* what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
|
|
3377
|
+
*
|
|
3378
|
+
* ── 왜 커맨드가 아닌가 (2026-08-17) ───────────────────────────────────────
|
|
3379
|
+
* 「배터리에 피크 억제 정책을 넣으면?」은 **행동이 아니라 모델의 변주**다. 커맨드로 만들면 트윈이 실물
|
|
3380
|
+
* 설비의 설정을 바꿀 수 있다는 뜻이 되는데, 제어는 범위 밖으로 두기로 했다(보호 계통·안전 사슬).
|
|
3381
|
+
* 그래서 자리 용량 변경과 같은 자리에 둔다 — fork 에서 「만약 이렇게 선언돼 있었다면」을 묻는 손잡이다.
|
|
3382
|
+
*
|
|
3383
|
+
* **선언에 없던 속성도 넣을 수 있다** — 그것이 「도입하면?」의 뜻이다. 다만 원본이 아니라 fork 에
|
|
3384
|
+
* 걸어야 한다: 실행 중 트윈에 걸면 그 트윈이 「우리 현장에 그 자동화가 있다」고 주장하게 된다.
|
|
3385
|
+
*/
|
|
3386
|
+
setResourceProperty(resourceId, propertyId, value) {
|
|
3387
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
3388
|
+
if (e.id !== resourceId) continue;
|
|
3389
|
+
const props = e.properties ??= [];
|
|
3390
|
+
const found = props.find((p) => p?.id === propertyId);
|
|
3391
|
+
if (found) found.value = String(value);
|
|
3392
|
+
else props.push({ id: propertyId, value: String(value) });
|
|
3393
|
+
return true;
|
|
3394
|
+
}
|
|
3395
|
+
return false;
|
|
3396
|
+
}
|
|
3337
3397
|
/** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
|
|
3338
3398
|
setLocationCapacity(locationId, capacity) {
|
|
3339
3399
|
const n = this.locations.get(locationId);
|
|
@@ -3473,11 +3533,21 @@ var FlowEngine = class {
|
|
|
3473
3533
|
this.rng = mulberry32((def.seed ?? 1) >>> 0);
|
|
3474
3534
|
this.speed = def.speed ?? 1;
|
|
3475
3535
|
this.gens = def.generators.map((spec) => ({ spec, nextMs: 0 }));
|
|
3536
|
+
this.interventions = (def.interventions ?? []).map((spec) => ({ spec, dueMs: 0, done: false }));
|
|
3537
|
+
this.interventionLog = [];
|
|
3538
|
+
this.overrideLog = (def.overrides ?? []).map((o) => ({
|
|
3539
|
+
...o,
|
|
3540
|
+
applied: this.setResourceProperty(o.resourceId, o.propertyId, o.value)
|
|
3541
|
+
}));
|
|
3476
3542
|
},
|
|
3477
3543
|
start: () => {
|
|
3478
3544
|
if (this.generating) return;
|
|
3479
3545
|
this.generating = true;
|
|
3480
3546
|
for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
3547
|
+
for (const iv of this.interventions) {
|
|
3548
|
+
iv.dueMs = this.clockMs + Math.max(0, Number(iv.spec.atMs) || 0);
|
|
3549
|
+
iv.done = false;
|
|
3550
|
+
}
|
|
3481
3551
|
},
|
|
3482
3552
|
pause: () => {
|
|
3483
3553
|
this.generating = false;
|
|
@@ -3485,6 +3555,9 @@ var FlowEngine = class {
|
|
|
3485
3555
|
reset: () => {
|
|
3486
3556
|
this.generating = false;
|
|
3487
3557
|
this.gens = [];
|
|
3558
|
+
this.interventions = [];
|
|
3559
|
+
this.interventionLog = [];
|
|
3560
|
+
this.overrideLog = [];
|
|
3488
3561
|
},
|
|
3489
3562
|
setSpeed: (f) => {
|
|
3490
3563
|
this.speed = f;
|
|
@@ -3493,6 +3566,7 @@ var FlowEngine = class {
|
|
|
3493
3566
|
tick(dtMs) {
|
|
3494
3567
|
const dt = dtMs * this.speed;
|
|
3495
3568
|
this.clockMs += dt;
|
|
3569
|
+
if (this.generating) this.applyInterventions();
|
|
3496
3570
|
if (this.generating) this.generate();
|
|
3497
3571
|
this.processFailures(dt);
|
|
3498
3572
|
this.processOrders();
|
|
@@ -3654,6 +3728,48 @@ var FlowEngine = class {
|
|
|
3654
3728
|
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
3655
3729
|
return clone;
|
|
3656
3730
|
}
|
|
3731
|
+
/**
|
|
3732
|
+
* 시각이 된 개입을 적용한다 — **한 번만, 그리고 결과를 남긴다.**
|
|
3733
|
+
*
|
|
3734
|
+
* 거절도 사실이다: 커널이 모르는 종류이거나 대상이 없으면 커맨드가 코드로 답하고, 우리는 그것을
|
|
3735
|
+
* 기록한다. 조용히 넘기면 「걸었는데 왜 안 바뀌나」를 아무도 답할 수 없다.
|
|
3736
|
+
*/
|
|
3737
|
+
applyInterventions() {
|
|
3738
|
+
for (const iv of this.interventions) {
|
|
3739
|
+
if (iv.done || this.clockMs < iv.dueMs) continue;
|
|
3740
|
+
iv.done = true;
|
|
3741
|
+
const ack = this.dispatch({
|
|
3742
|
+
commandId: `iv-${this.tenantId}-${iv.spec.kind}-${iv.dueMs}`,
|
|
3743
|
+
type: iv.spec.kind,
|
|
3744
|
+
tenantId: this.tenantId,
|
|
3745
|
+
args: iv.spec.args ?? {}
|
|
3746
|
+
});
|
|
3747
|
+
this.interventionLog.push({
|
|
3748
|
+
atMs: iv.spec.atMs,
|
|
3749
|
+
kind: iv.spec.kind,
|
|
3750
|
+
...iv.spec.args ? { args: iv.spec.args } : {},
|
|
3751
|
+
applied: ack.accepted === true,
|
|
3752
|
+
...ack.accepted === true ? {} : { refusedCode: ack.errorCode ?? "refused" }
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* 가정한 개입이 어떻게 됐나 — 소비처(예측 화면·AI)가 그대로 옮긴다.
|
|
3758
|
+
*
|
|
3759
|
+
* 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
|
|
3760
|
+
*/
|
|
3761
|
+
interventionOutcomes() {
|
|
3762
|
+
return this.interventionLog.map((o) => ({ ...o }));
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
|
|
3766
|
+
*
|
|
3767
|
+
* 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
|
|
3768
|
+
* 화면·AI 가 그 사실을 함께 말해야 한다.
|
|
3769
|
+
*/
|
|
3770
|
+
declarationOverrides() {
|
|
3771
|
+
return this.overrideLog.map((o) => ({ ...o }));
|
|
3772
|
+
}
|
|
3657
3773
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
3658
3774
|
/**
|
|
3659
3775
|
* 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
|
|
@@ -6126,7 +6242,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6126
6242
|
* ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
|
|
6127
6243
|
* ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
|
|
6128
6244
|
*/
|
|
6129
|
-
deriveLoad(atMs) {
|
|
6245
|
+
deriveLoad(atMs, dtMs) {
|
|
6130
6246
|
if (this.observing) return;
|
|
6131
6247
|
let total = 0;
|
|
6132
6248
|
let counted = 0;
|
|
@@ -6153,13 +6269,15 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6153
6269
|
for (const m of this.equipment.values()) {
|
|
6154
6270
|
const g = Number(m.generatedKW);
|
|
6155
6271
|
if (Number.isFinite(g) && g > 0) generated += g;
|
|
6156
|
-
const d = Number(m.dischargeKW);
|
|
6157
|
-
if (Number.isFinite(d) && d > 0) discharged += d;
|
|
6158
6272
|
const c = Number(m.chargeKW);
|
|
6159
6273
|
if (Number.isFinite(c) && c > 0) charged += c;
|
|
6274
|
+
if (this.hasDispatchPolicy(m.id)) continue;
|
|
6275
|
+
const d = Number(m.dischargeKW);
|
|
6276
|
+
if (Number.isFinite(d) && d > 0) discharged += d;
|
|
6160
6277
|
}
|
|
6161
6278
|
const gross = total + charged;
|
|
6162
|
-
const
|
|
6279
|
+
const dispatched = this.dispatchStorage(Math.max(0, gross - generated - discharged), atMs, dtMs);
|
|
6280
|
+
const net = Math.max(0, gross - generated - discharged - dispatched);
|
|
6163
6281
|
this.closeDue(atMs);
|
|
6164
6282
|
this.openWindow(atMs);
|
|
6165
6283
|
const w = this.open;
|
|
@@ -6171,6 +6289,80 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6171
6289
|
this.revision++;
|
|
6172
6290
|
this.judgeOpenWindow(atMs);
|
|
6173
6291
|
}
|
|
6292
|
+
/** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
|
|
6293
|
+
hasDispatchPolicy(equipmentId) {
|
|
6294
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6295
|
+
if (e.id !== equipmentId) continue;
|
|
6296
|
+
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;
|
|
6297
|
+
}
|
|
6298
|
+
return false;
|
|
6299
|
+
}
|
|
6300
|
+
/**
|
|
6301
|
+
* 선언된 정책으로 축전지를 방전시킨다 — **선언이 없으면 아무것도 하지 않는다.**
|
|
6302
|
+
*
|
|
6303
|
+
* 돌려주는 값은 이번 틱에 계통에서 덜어낸 kW 다. 부작용으로 그 설비의 `dischargeKW`·`soc` 를 고치고
|
|
6304
|
+
* 사실(`energy.equipment`)을 낸다 — 저널을 읽는 쪽이 「배터리가 무엇을 했나」를 되짚을 수 있어야 한다.
|
|
6305
|
+
*
|
|
6306
|
+
* ── 무엇을 하지 않나 ──────────────────────────────────────────────────────
|
|
6307
|
+
* · 충전 계획을 만들지 않는다(언제 채울지는 요금 시간대의 일이고, 그 모델은 아직 없다).
|
|
6308
|
+
* · 용량·임계·최대율이 하나라도 없으면 방전하지 않는다 — 짐작한 수로 피크를 깎지 않는다.
|
|
6309
|
+
* · 미러 트윈에서는 불리지 않는다(호출자가 이미 관측 모드를 걸러낸다).
|
|
6310
|
+
*/
|
|
6311
|
+
dispatchStorage(demandBeforeStorageKW, atMs, dtMs) {
|
|
6312
|
+
let dispatched = 0;
|
|
6313
|
+
for (const e of this.boardDef?.equipment ?? []) {
|
|
6314
|
+
const capacity = this.numberProperty(e, EMS_PROPERTY.capacityKWh);
|
|
6315
|
+
const above = this.numberProperty(e, EMS_PROPERTY.dischargeAboveKW);
|
|
6316
|
+
const maxRate = this.numberProperty(e, EMS_PROPERTY.maxDischargeKW);
|
|
6317
|
+
if (capacity === void 0 || above === void 0 || maxRate === void 0) continue;
|
|
6318
|
+
if (!(capacity > 0) || !(maxRate > 0)) continue;
|
|
6319
|
+
const state = this.equipment.get(e.id);
|
|
6320
|
+
if (!state) continue;
|
|
6321
|
+
if (!Number.isFinite(Number(state.soc))) {
|
|
6322
|
+
const seed = this.numberProperty(e, EMS_PROPERTY.initialSoc);
|
|
6323
|
+
if (seed !== void 0) state.soc = seed;
|
|
6324
|
+
}
|
|
6325
|
+
const over = demandBeforeStorageKW - dispatched - above;
|
|
6326
|
+
if (over <= 0) {
|
|
6327
|
+
if (Number(state.dischargeKW) > 0) {
|
|
6328
|
+
state.dischargeKW = 0;
|
|
6329
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, ...Number.isFinite(Number(state.soc)) ? { soc: Number(state.soc) } : {} });
|
|
6330
|
+
}
|
|
6331
|
+
continue;
|
|
6332
|
+
}
|
|
6333
|
+
const reserve = this.numberProperty(e, EMS_PROPERTY.reserveSoc) ?? 0;
|
|
6334
|
+
const soc = Number(state.soc);
|
|
6335
|
+
if (!Number.isFinite(soc)) continue;
|
|
6336
|
+
const usableKWh = (soc - reserve) / 100 * capacity;
|
|
6337
|
+
if (!(usableKWh > 0)) {
|
|
6338
|
+
if (Number(state.dischargeKW) > 0) {
|
|
6339
|
+
state.dischargeKW = 0;
|
|
6340
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: 0, soc });
|
|
6341
|
+
}
|
|
6342
|
+
continue;
|
|
6343
|
+
}
|
|
6344
|
+
const dtHours = dtMs / 36e5;
|
|
6345
|
+
const rateCap = dtHours > 0 ? usableKWh / dtHours : maxRate;
|
|
6346
|
+
const kW = Math.min(maxRate, over, rateCap);
|
|
6347
|
+
if (!(kW > 0)) continue;
|
|
6348
|
+
const drained = dtHours > 0 ? kW * dtHours / capacity * 100 : 0;
|
|
6349
|
+
const nextSoc = Math.max(reserve, soc - drained);
|
|
6350
|
+
state.dischargeKW = kW;
|
|
6351
|
+
state.soc = nextSoc;
|
|
6352
|
+
this.emitEnergyEquipment(e.id, atMs, { dischargeKW: kW, soc: nextSoc });
|
|
6353
|
+
dispatched += kW;
|
|
6354
|
+
}
|
|
6355
|
+
return dispatched;
|
|
6356
|
+
}
|
|
6357
|
+
/**
|
|
6358
|
+
* 파생된 설비 에너지 상태를 사실로 낸다 — **만든 값임을 밝힌다.**
|
|
6359
|
+
*
|
|
6360
|
+
* 계측으로 들어온 것과 같은 자리에 두면서 종류를 적지 않으면, 저널을 읽는 쪽이 우리가 계산한 방전을
|
|
6361
|
+
* 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
|
|
6362
|
+
*/
|
|
6363
|
+
emitEnergyEquipment(equipmentId, atMs, fields) {
|
|
6364
|
+
this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
|
|
6365
|
+
}
|
|
6174
6366
|
/** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
|
|
6175
6367
|
numberProperty(resource, id) {
|
|
6176
6368
|
for (const p of resource?.properties ?? []) {
|
|
@@ -6183,7 +6375,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
6183
6375
|
tick(dtMs) {
|
|
6184
6376
|
super.tick(dtMs);
|
|
6185
6377
|
const at = this.nowMs();
|
|
6186
|
-
this.deriveLoad(at);
|
|
6378
|
+
this.deriveLoad(at, dtMs);
|
|
6187
6379
|
this.closeDue(at);
|
|
6188
6380
|
}
|
|
6189
6381
|
getSnapshot() {
|
package/package.json
CHANGED