@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.
@@ -1210,8 +1210,27 @@ export interface MeterPointState {
1210
1210
  export interface DemandWindowState {
1211
1211
  startMs: number;
1212
1212
  endMs: number;
1213
- /** 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다). */
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;
@@ -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;
@@ -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
- if (w.maxKW === undefined || total > w.maxKW)
458
- w.maxKW = total;
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 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
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
- this.deriveLoad(at);
630
+ /* 틱 길이를 함께 넘긴다 — 방전량을 에너지로 바꿀 때 필요하다(필드로 숨기면 어디서 온 값인지 흐려진다). */
631
+ this.deriveLoad(at, dtMs);
488
632
  this.closeDue(at);
489
633
  }
490
634
  getSnapshot() {
@@ -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[];
@@ -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,
@@ -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
  *
@@ -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) — 시각으로 바뀌는 모든 판정의 단일 기준.
@@ -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 || total > w.maxKW) w.maxKW = total;
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 ? total : (w.meanKW * (w.samples - 1) + total) / w.samples;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {