@operato/twin-kernel 0.7.15 → 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.
@@ -1433,6 +1433,40 @@ 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
+ 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;
1436
1470
  }
1437
1471
  export interface ScenarioControl {
1438
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;
@@ -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
- const net = Math.max(0, gross - generated - discharged);
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,91 @@ 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
+ 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
+ }
503
607
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
504
608
  numberProperty(resource, id) {
505
609
  for (const p of resource?.properties ?? []) {
@@ -523,7 +627,8 @@ export class EmsKernel extends FlowEngine {
523
627
  * 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
524
628
  */
525
629
  const at = this.nowMs();
526
- this.deriveLoad(at);
630
+ /* 틱 길이를 함께 넘긴다 — 방전량을 에너지로 바꿀 때 필요하다(필드로 숨기면 어디서 온 값인지 흐려진다). */
631
+ this.deriveLoad(at, dtMs);
527
632
  this.closeDue(at);
528
633
  }
529
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
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -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) — 시각으로 바뀌는 모든 판정의 단일 기준.
@@ -6126,7 +6216,7 @@ var EmsKernel = class extends FlowEngine {
6126
6216
  * ③ 멈춘 설비는 `power.standbyKW` 를 선언한 만큼만 센다. 선언이 없으면 0 이 아니라 **모름**이다.
6127
6217
  * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
6128
6218
  */
6129
- deriveLoad(atMs) {
6219
+ deriveLoad(atMs, dtMs) {
6130
6220
  if (this.observing) return;
6131
6221
  let total = 0;
6132
6222
  let counted = 0;
@@ -6153,13 +6243,15 @@ var EmsKernel = class extends FlowEngine {
6153
6243
  for (const m of this.equipment.values()) {
6154
6244
  const g = Number(m.generatedKW);
6155
6245
  if (Number.isFinite(g) && g > 0) generated += g;
6156
- const d = Number(m.dischargeKW);
6157
- if (Number.isFinite(d) && d > 0) discharged += d;
6158
6246
  const c = Number(m.chargeKW);
6159
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;
6160
6251
  }
6161
6252
  const gross = total + charged;
6162
- const net = Math.max(0, gross - generated - discharged);
6253
+ const dispatched = this.dispatchStorage(Math.max(0, gross - generated - discharged), atMs, dtMs);
6254
+ const net = Math.max(0, gross - generated - discharged - dispatched);
6163
6255
  this.closeDue(atMs);
6164
6256
  this.openWindow(atMs);
6165
6257
  const w = this.open;
@@ -6171,6 +6263,76 @@ var EmsKernel = class extends FlowEngine {
6171
6263
  this.revision++;
6172
6264
  this.judgeOpenWindow(atMs);
6173
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
+ }
6174
6336
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
6175
6337
  numberProperty(resource, id) {
6176
6338
  for (const p of resource?.properties ?? []) {
@@ -6183,7 +6345,7 @@ var EmsKernel = class extends FlowEngine {
6183
6345
  tick(dtMs) {
6184
6346
  super.tick(dtMs);
6185
6347
  const at = this.nowMs();
6186
- this.deriveLoad(at);
6348
+ this.deriveLoad(at, dtMs);
6187
6349
  this.closeDue(at);
6188
6350
  }
6189
6351
  getSnapshot() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.15",
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": {