@operato/twin-kernel 0.7.86 → 0.7.88

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.
@@ -2041,6 +2041,19 @@ export interface EnergyState {
2041
2041
  * 청구서보다 이쪽이 먼저다: 청구서는 끝난 기간의 정산이고 이것은 지금 적용되는 값이다.
2042
2042
  */
2043
2043
  tariffBasis?: EnergyTariffBasisData;
2044
+ /**
2045
+ * **선언된 요금 기준들** — 유효 구간별. `tariffBasis` 는 이 중 지금 유효한 것이다.
2046
+ *
2047
+ * ── 왜 여럿을 드나 (2026-08-30) ──────────────────────────────────────────
2048
+ * 선언은 **예정될 수 있다.** 한전은 다음 달 요금을 미리 공표한다. 하나만 들면 9월 선언이 들어오는
2049
+ * 순간 8월 선언을 덮어써서 **오늘 요금이 다음 달 단가로 계산된다.** 오류는 나지 않는다.
2050
+ *
2051
+ * 처음에는 예정된 선언을 유입에서 거부해 이 상황을 막았다. 그것은 정상적인 사실을 막는 것이라,
2052
+ * 담을 자리를 만들고 거부를 풀었다.
2053
+ *
2054
+ * 고르는 규칙은 **커널에 한 벌만 둔다.** 읽는 쪽이 각자 고르면 화면마다 다른 요금이 나온다.
2055
+ */
2056
+ tariffBasisSchedule?: EnergyTariffBasisData[];
2044
2057
  /**
2045
2058
  * 이 트윈의 **발전 정격**과 그 근거 — 이용률의 분모(§ `generationRated`).
2046
2059
  *
@@ -2056,8 +2069,12 @@ export interface EnergyState {
2056
2069
  * 마지막으로 받은 **발전 단가** — 이 기간에 낸 전기 1kWh 의 값.
2057
2070
  *
2058
2071
  * 창 밖의 단가도 이 창에 적용될 수 있어(단가는 기간마다 정해진다) 상태로 이어 간다.
2072
+ *
2073
+ * 예정된 단가는 `generationPriceSchedule` 에 함께 든다 — 이 칸은 **지금 유효한 것**이다.
2059
2074
  */
2060
2075
  generationPrice?: EnergyGenerationPriceData;
2076
+ /** **선언된 발전 단가들** — 유효 구간별. 예정된 것을 담는다(§`tariffBasisSchedule`). */
2077
+ generationPriceSchedule?: EnergyGenerationPriceData[];
2061
2078
  /**
2062
2079
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
2063
2080
  *
@@ -133,6 +133,13 @@ export declare class EmsKernel extends FlowEngine {
133
133
  * 새것을 쓴다 — 사업자가 그 주기의 기준을 고칠 수 있다.
134
134
  */
135
135
  private applyTariffBasis;
136
+ /**
137
+ * 시계가 선언의 경계를 넘으면 **지금 유효한 선언이 바뀐다.**
138
+ *
139
+ * 표본이 들어올 때만 다시 고르면, 9월 1일 0시에 단가가 바뀌었는데 다음 표본이 올 때까지 8월 단가로
140
+ * 계산한다. 태양광은 밤에 표본이 없어 그 간격이 길다.
141
+ */
142
+ protected refreshDeclarationsInEffect(nowMs: number): void;
136
143
  /**
137
144
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
138
145
  *
@@ -221,8 +228,12 @@ export declare class EmsKernel extends FlowEngine {
221
228
  private lastBill?;
222
229
  /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
223
230
  private tariffBasis?;
231
+ /** 선언된 요금 기준들 — 예정된 것을 포함한다(§`EnergyState.tariffBasisSchedule`). */
232
+ private tariffBasisSchedule?;
224
233
  /** 마지막으로 받은 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. */
225
234
  private generationPrice?;
235
+ /** 선언된 발전 단가들 — 예정된 것을 포함한다. */
236
+ private generationPriceSchedule?;
226
237
  /**
227
238
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
228
239
  *
@@ -354,13 +354,8 @@ export class EmsKernel extends FlowEngine {
354
354
  const d = envelope.data;
355
355
  if (!d?.from || !d?.to)
356
356
  return;
357
- const fromMs = Date.parse(String(d.from));
358
- if (!Number.isFinite(fromMs))
359
- return;
360
- const priorFromMs = Date.parse(String(this.generationPrice?.from ?? ''));
361
- if (Number.isFinite(priorFromMs) && fromMs < priorFromMs)
362
- return;
363
- this.generationPrice = { ...d };
357
+ this.generationPriceSchedule = putDeclaration(this.generationPriceSchedule, d);
358
+ this.generationPrice = inEffectAt(this.generationPriceSchedule, this.nowMs());
364
359
  this.revision++;
365
360
  }
366
361
  /**
@@ -373,15 +368,25 @@ export class EmsKernel extends FlowEngine {
373
368
  const d = envelope.data;
374
369
  if (!d?.from || !d?.to)
375
370
  return;
376
- const fromMs = Date.parse(String(d.from));
377
- if (!Number.isFinite(fromMs))
378
- return;
379
- const priorFromMs = Date.parse(String(this.tariffBasis?.from ?? ''));
380
- if (Number.isFinite(priorFromMs) && fromMs < priorFromMs)
381
- return;
382
- this.tariffBasis = { ...d };
371
+ this.tariffBasisSchedule = putDeclaration(this.tariffBasisSchedule, d);
372
+ this.tariffBasis = inEffectAt(this.tariffBasisSchedule, this.nowMs());
383
373
  this.revision++;
384
374
  }
375
+ /**
376
+ * 시계가 선언의 경계를 넘으면 **지금 유효한 선언이 바뀐다.**
377
+ *
378
+ * 표본이 들어올 때만 다시 고르면, 9월 1일 0시에 단가가 바뀌었는데 다음 표본이 올 때까지 8월 단가로
379
+ * 계산한다. 태양광은 밤에 표본이 없어 그 간격이 길다.
380
+ */
381
+ refreshDeclarationsInEffect(nowMs) {
382
+ const tariff = inEffectAt(this.tariffBasisSchedule, nowMs);
383
+ const price = inEffectAt(this.generationPriceSchedule, nowMs);
384
+ if (tariff !== this.tariffBasis || price !== this.generationPrice) {
385
+ this.tariffBasis = tariff;
386
+ this.generationPrice = price;
387
+ this.revision++;
388
+ }
389
+ }
385
390
  /**
386
391
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
387
392
  *
@@ -713,8 +718,12 @@ export class EmsKernel extends FlowEngine {
713
718
  lastBill;
714
719
  /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
715
720
  tariffBasis;
721
+ /** 선언된 요금 기준들 — 예정된 것을 포함한다(§`EnergyState.tariffBasisSchedule`). */
722
+ tariffBasisSchedule;
716
723
  /** 마지막으로 받은 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. */
717
724
  generationPrice;
725
+ /** 선언된 발전 단가들 — 예정된 것을 포함한다. */
726
+ generationPriceSchedule;
718
727
  /**
719
728
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
720
729
  *
@@ -1599,6 +1608,8 @@ export class EmsKernel extends FlowEngine {
1599
1608
  * 계량은 이미 시계로 마감한다(`closeDue`). 한 커널 안에서 규칙이 두 벌일 이유가 없다.
1600
1609
  */
1601
1610
  this.closeGenerationPeriodsDue(at);
1611
+ /* 시계가 선언의 경계를 넘으면 지금 유효한 선언이 바뀐다 — 표본을 기다리지 않는다. */
1612
+ this.refreshDeclarationsInEffect(at);
1602
1613
  /* 틱 길이를 함께 넘긴다 — 방전량을 에너지로 바꿀 때 필요하다(필드로 숨기면 어디서 온 값인지 흐려진다). */
1603
1614
  this.deriveLoad(at, dtMs);
1604
1615
  this.closeDue(at);
@@ -1657,10 +1668,21 @@ export class EmsKernel extends FlowEngine {
1657
1668
  /* 청구서도 이어받는다 — 재기동마다 사라지면 우리 계산과 견줄 기준이 없어진다. */
1658
1669
  if (e.lastBill?.from && e.lastBill?.to)
1659
1670
  this.lastBill = { ...e.lastBill };
1671
+ /*
1672
+ * 선언은 **목록째** 이어받는다 — 예정된 것이 사라지면 재기동 뒤에 시행일이 와도 옛 단가를 쓴다.
1673
+ * 이어받은 뒤 지금 시각으로 다시 고른다: 멈춰 있는 동안 시행일이 지났을 수 있다.
1674
+ */
1675
+ if (Array.isArray(e.tariffBasisSchedule))
1676
+ this.tariffBasisSchedule = e.tariffBasisSchedule.map(d => ({ ...d }));
1677
+ if (Array.isArray(e.generationPriceSchedule))
1678
+ this.generationPriceSchedule = e.generationPriceSchedule.map(d => ({ ...d }));
1660
1679
  if (e.tariffBasis?.from && e.tariffBasis?.to)
1661
1680
  this.tariffBasis = { ...e.tariffBasis };
1662
1681
  if (e.generationPrice?.from && e.generationPrice?.to)
1663
1682
  this.generationPrice = { ...e.generationPrice };
1683
+ if (this.tariffBasisSchedule?.length || this.generationPriceSchedule?.length) {
1684
+ this.refreshDeclarationsInEffect(this.nowMs());
1685
+ }
1664
1686
  /* 이 주기의 최고도 이어받는다 — 재기동마다 0 으로 돌아가면 이미 넘은 것을 못 넘었다고 말한다. */
1665
1687
  if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since)
1666
1688
  this.demandHigh = { ...e.demandHigh };
@@ -1691,6 +1713,14 @@ export class EmsKernel extends FlowEngine {
1691
1713
  ...(this.lastBill ? { lastBill: { ...this.lastBill } } : {}),
1692
1714
  ...(this.tariffBasis ? { tariffBasis: { ...this.tariffBasis } } : {}),
1693
1715
  ...(this.generationPrice ? { generationPrice: { ...this.generationPrice } } : {}),
1716
+ /*
1717
+ * **예정된 선언도 함께 낸다.** 지금 유효한 것만 이어받으면, 재기동한 뒤 9월이 되어도 8월 단가로
1718
+ * 계산한다 — 9월 선언이 어디에도 남아 있지 않기 때문이다. 오늘 이 부류를 세 번 겪었다.
1719
+ */
1720
+ ...(this.tariffBasisSchedule?.length ? { tariffBasisSchedule: this.tariffBasisSchedule.map(d => ({ ...d })) } : {}),
1721
+ ...(this.generationPriceSchedule?.length
1722
+ ? { generationPriceSchedule: this.generationPriceSchedule.map(d => ({ ...d })) }
1723
+ : {}),
1694
1724
  ...(() => {
1695
1725
  const r = this.generationRated();
1696
1726
  return r ? { generationRated: r } : {};
@@ -1705,3 +1735,41 @@ export class EmsKernel extends FlowEngine {
1705
1735
  return { ...snap, energy };
1706
1736
  }
1707
1737
  }
1738
+ /**
1739
+ * 선언 하나를 목록에 넣는다 — **같은 유효 구간이면 새것이 이긴다.**
1740
+ *
1741
+ * 사업자가 그 주기의 기준을 고칠 수 있다. 같은 구간에 두 선언이 남으면 어느 것이 맞는지 말할 수 없다.
1742
+ *
1743
+ * 목록은 시작 시각 순으로 두고 **24개까지** 든다. 선언은 주기마다 하나이므로 24개면 두 해치다 —
1744
+ * 그보다 오래된 것은 지난 기록에서 읽는다(상태는 「지금」을 위한 것이다).
1745
+ */
1746
+ function putDeclaration(list, d) {
1747
+ const rest = (list ?? []).filter(x => !(x.from === d.from && x.to === d.to));
1748
+ const next = [...rest, { ...d }].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
1749
+ return next.length > 24 ? next.slice(next.length - 24) : next;
1750
+ }
1751
+ /**
1752
+ * **이 시각에 유효한 선언** — 시작했고 아직 끝나지 않은 것.
1753
+ *
1754
+ * 유효한 것이 없으면 답하지 않는다. 가장 가까운 것을 골라 주지 않는다 — 아직 시작하지 않은 단가로
1755
+ * 오늘을 계산하거나, 이미 끝난 단가를 지금 것인 양 쓰게 된다. 둘 다 오류 없이 틀린 값을 만든다.
1756
+ *
1757
+ * 구간이 겹치면 **나중에 시작한 것**이 이긴다(더 좁게 정한 선언이 앞선다).
1758
+ */
1759
+ function inEffectAt(list, atMs) {
1760
+ let best;
1761
+ let bestFrom = -Infinity;
1762
+ for (const d of list ?? []) {
1763
+ const from = Date.parse(String(d.from));
1764
+ const to = Date.parse(String(d.to));
1765
+ if (!Number.isFinite(from) || !Number.isFinite(to))
1766
+ continue;
1767
+ if (atMs < from || atMs >= to)
1768
+ continue;
1769
+ if (from > bestFrom) {
1770
+ best = d;
1771
+ bestFrom = from;
1772
+ }
1773
+ }
1774
+ return best;
1775
+ }
@@ -35,6 +35,11 @@ export interface EnergyIngestOptions {
35
35
  * 그때도 `twin-local` 이되 범위는 비어 있다.
36
36
  */
37
37
  scopeId?: string;
38
+ /**
39
+ * **지금** — 아직 오지 않은 시각의 사실을 받지 않기 위해 쓴다. 주지 않으면 `defaultEventTime` 을 쓰고,
40
+ * 그것도 없으면 판단하지 않는다(없는 기준으로 거절하지 않는다).
41
+ */
42
+ nowISO?: string;
38
43
  }
39
44
  export interface EnergyIngestResult {
40
45
  accepted: CanonicalEnvelope[];
@@ -200,14 +205,6 @@ export interface EnergyBillRecord {
200
205
  billingDemandKW?: number;
201
206
  }
202
207
  export declare function isEnergyBillRecord(record: unknown): boolean;
203
- /**
204
- * 이 사실의 **주체를 무엇으로 부를까** — 선언이 있으면 그 이름, 없으면 이름표와 그 범위.
205
- *
206
- * 선언이 없을 때 트윈 범위를 이름에 넣는 이유는 **겹침을 숨기지 않기 위해서**다. 넣지 않으면 서로 다른
207
- * 설비의 같은 날이 한 사실이 되고, 여러 현장을 함께 계산하는 곳에서 한쪽이 조용히 사라진다(실측).
208
- * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
209
- * 척하지 않는다.
210
- */
211
208
  export declare function resolveSubject(kind: 'equipment' | 'meter', localId: string, opts: EnergyIngestOptions): {
212
209
  subject: string;
213
210
  basis: 'declared' | 'twin-local';
@@ -452,6 +452,11 @@ export function ingestEnergyGenerationPeriodRecords(records, opts) {
452
452
  const subj = resolveSubject('equipment', equipmentId, opts);
453
453
  data.subject = subj.subject;
454
454
  data.subjectBasis = subj.basis;
455
+ const futureError = futureFactError(span.to, opts);
456
+ if (futureError) {
457
+ rejected.push({ record: r, errors: [futureError] });
458
+ continue;
459
+ }
455
460
  accepted.push({
456
461
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
457
462
  eventType: ENERGY_EVENT.generationPeriod,
@@ -607,6 +612,35 @@ export function isEnergyBillRecord(record) {
607
612
  * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
608
613
  * 척하지 않는다.
609
614
  */
615
+ /**
616
+ * **아직 오지 않은 시각의 사실은 받지 않는다.**
617
+ *
618
+ * ── 왜 필요한가 (2026-08-30) ──────────────────────────────────────────────
619
+ * 한 원본이 **끝나지 않은 이번 달**의 요금 정보를 청구서로 보냈다. 청구서의 사건 시각은 기간의 끝이라
620
+ * 그 사실이 **내일** 일어난 것으로 저널에 적혔다. 성과 계산은 「가장 늦은 사건」으로 구간을 잡으므로
621
+ * 구간 전체가 미래로 밀렸고, 그 현장의 화면에서 어제 발전량이 사라졌다. 오류는 하나도 나지 않았다.
622
+ *
623
+ * 지난 기록 채우기는 이미 이 판단을 하고 있었다(§`judgeBackfill`). 라이브 문에만 없어서 **한 종류에
624
+ * 규칙이 두 벌**이었다. 같은 사실이 어느 문으로 들어오느냐에 따라 받아지기도 하고 거절되기도 했다.
625
+ *
626
+ * ── 기준 선언은 이 판정을 받지 않는다 (2026-08-30, 두 번째 판단) ────────────
627
+ * 요금 기준·발전 단가는 **예정될 수 있다.** 한전은 다음 달 요금을 미리 공표하고, 공정 조건도 시행일을
628
+ * 앞두고 정해진다. 「9월부터 이 단가」는 오늘 성립하는 사실이다.
629
+ *
630
+ * 처음에는 이 둘도 거부했다. 그러면 정상적인 사실이 막힌다. 받되, **예정된 선언이 지금 것을 덮지 않게**
631
+ * 커널이 유효 구간으로 고른다(§`EnergyState.tariffBasisSchedule`). 받아 놓고 아무 데나 쓰면
632
+ * 오늘 요금이 다음 달 단가로 계산된다 — 그것이 이 문을 열기 전에 먼저 만들어야 했던 자리다.
633
+ */
634
+ function futureFactError(eventTime, opts) {
635
+ const nowText = opts.nowISO ?? opts.defaultEventTime;
636
+ if (!nowText)
637
+ return undefined;
638
+ const nowMs = Date.parse(nowText);
639
+ const atMs = Date.parse(eventTime);
640
+ if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs)
641
+ return undefined;
642
+ return `아직 오지 않은 시각의 사실이다(${eventTime}) — 끝나지 않은 기간은 마감된 사실이 아니다`;
643
+ }
610
644
  export function resolveSubject(kind, localId, opts) {
611
645
  const declared = opts.identityOf?.(kind, localId);
612
646
  if (declared)
@@ -699,6 +733,11 @@ export function ingestEnergyUsagePeriodRecords(records, opts) {
699
733
  ...(currency ? { currency } : {}),
700
734
  ...(basis ? { basis } : {})
701
735
  };
736
+ const futureError = futureFactError(span.to, opts);
737
+ if (futureError) {
738
+ rejected.push({ record: r, errors: [futureError] });
739
+ continue;
740
+ }
702
741
  accepted.push({
703
742
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
704
743
  eventType: ENERGY_EVENT.usagePeriod,
@@ -745,6 +784,11 @@ export function ingestEnergyBillRecords(records, opts) {
745
784
  ...(currency ? { currency } : {}),
746
785
  ...(billingDemandKW !== undefined ? { billingDemandKW } : {})
747
786
  };
787
+ const futureError = futureFactError(span.to, opts);
788
+ if (futureError) {
789
+ rejected.push({ record: r, errors: [futureError] });
790
+ continue;
791
+ }
748
792
  accepted.push({
749
793
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, '', span.from, span.to),
750
794
  eventType: ENERGY_EVENT.bill,
@@ -2824,6 +2824,11 @@ function ingestEnergyGenerationPeriodRecords(records, opts) {
2824
2824
  const subj = resolveSubject("equipment", equipmentId, opts);
2825
2825
  data.subject = subj.subject;
2826
2826
  data.subjectBasis = subj.basis;
2827
+ const futureError = futureFactError(span.to, opts);
2828
+ if (futureError) {
2829
+ rejected.push({ record: r, errors: [futureError] });
2830
+ continue;
2831
+ }
2827
2832
  accepted.push({
2828
2833
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
2829
2834
  eventType: ENERGY_EVENT.generationPeriod,
@@ -2919,6 +2924,14 @@ function isEnergyBillRecord(record) {
2919
2924
  if (r.from === void 0 || r.to === void 0) return false;
2920
2925
  return r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0;
2921
2926
  }
2927
+ function futureFactError(eventTime, opts) {
2928
+ const nowText = opts.nowISO ?? opts.defaultEventTime;
2929
+ if (!nowText) return void 0;
2930
+ const nowMs = Date.parse(nowText);
2931
+ const atMs = Date.parse(eventTime);
2932
+ if (!Number.isFinite(nowMs) || !Number.isFinite(atMs) || atMs <= nowMs) return void 0;
2933
+ return `\uC544\uC9C1 \uC624\uC9C0 \uC54A\uC740 \uC2DC\uAC01\uC758 \uC0AC\uC2E4\uC774\uB2E4(${eventTime}) \u2014 \uB05D\uB098\uC9C0 \uC54A\uC740 \uAE30\uAC04\uC740 \uB9C8\uAC10\uB41C \uC0AC\uC2E4\uC774 \uC544\uB2C8\uB2E4`;
2934
+ }
2922
2935
  function resolveSubject(kind, localId, opts) {
2923
2936
  const declared = opts.identityOf?.(kind, localId);
2924
2937
  if (declared) return { subject: declared, basis: "declared" };
@@ -2997,6 +3010,11 @@ function ingestEnergyUsagePeriodRecords(records, opts) {
2997
3010
  ...currency ? { currency } : {},
2998
3011
  ...basis ? { basis } : {}
2999
3012
  };
3013
+ const futureError = futureFactError(span.to, opts);
3014
+ if (futureError) {
3015
+ rejected.push({ record: r, errors: [futureError] });
3016
+ continue;
3017
+ }
3000
3018
  accepted.push({
3001
3019
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
3002
3020
  eventType: ENERGY_EVENT.usagePeriod,
@@ -3037,6 +3055,11 @@ function ingestEnergyBillRecords(records, opts) {
3037
3055
  ...currency ? { currency } : {},
3038
3056
  ...billingDemandKW !== void 0 ? { billingDemandKW } : {}
3039
3057
  };
3058
+ const futureError = futureFactError(span.to, opts);
3059
+ if (futureError) {
3060
+ rejected.push({ record: r, errors: [futureError] });
3061
+ continue;
3062
+ }
3040
3063
  accepted.push({
3041
3064
  eventId: periodFactId(opts.tenantId, ENERGY_EVENT.bill, "", span.from, span.to),
3042
3065
  eventType: ENERGY_EVENT.bill,
@@ -8640,11 +8663,8 @@ var EmsKernel = class extends FlowEngine {
8640
8663
  applyGenerationPrice(envelope) {
8641
8664
  const d = envelope.data;
8642
8665
  if (!d?.from || !d?.to) return;
8643
- const fromMs = Date.parse(String(d.from));
8644
- if (!Number.isFinite(fromMs)) return;
8645
- const priorFromMs = Date.parse(String(this.generationPrice?.from ?? ""));
8646
- if (Number.isFinite(priorFromMs) && fromMs < priorFromMs) return;
8647
- this.generationPrice = { ...d };
8666
+ this.generationPriceSchedule = putDeclaration(this.generationPriceSchedule, d);
8667
+ this.generationPrice = inEffectAt(this.generationPriceSchedule, this.nowMs());
8648
8668
  this.revision++;
8649
8669
  }
8650
8670
  /**
@@ -8656,13 +8676,25 @@ var EmsKernel = class extends FlowEngine {
8656
8676
  applyTariffBasis(envelope) {
8657
8677
  const d = envelope.data;
8658
8678
  if (!d?.from || !d?.to) return;
8659
- const fromMs = Date.parse(String(d.from));
8660
- if (!Number.isFinite(fromMs)) return;
8661
- const priorFromMs = Date.parse(String(this.tariffBasis?.from ?? ""));
8662
- if (Number.isFinite(priorFromMs) && fromMs < priorFromMs) return;
8663
- this.tariffBasis = { ...d };
8679
+ this.tariffBasisSchedule = putDeclaration(this.tariffBasisSchedule, d);
8680
+ this.tariffBasis = inEffectAt(this.tariffBasisSchedule, this.nowMs());
8664
8681
  this.revision++;
8665
8682
  }
8683
+ /**
8684
+ * 시계가 선언의 경계를 넘으면 **지금 유효한 선언이 바뀐다.**
8685
+ *
8686
+ * 표본이 들어올 때만 다시 고르면, 9월 1일 0시에 단가가 바뀌었는데 다음 표본이 올 때까지 8월 단가로
8687
+ * 계산한다. 태양광은 밤에 표본이 없어 그 간격이 길다.
8688
+ */
8689
+ refreshDeclarationsInEffect(nowMs) {
8690
+ const tariff = inEffectAt(this.tariffBasisSchedule, nowMs);
8691
+ const price = inEffectAt(this.generationPriceSchedule, nowMs);
8692
+ if (tariff !== this.tariffBasis || price !== this.generationPrice) {
8693
+ this.tariffBasis = tariff;
8694
+ this.generationPrice = price;
8695
+ this.revision++;
8696
+ }
8697
+ }
8666
8698
  /**
8667
8699
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
8668
8700
  *
@@ -8881,8 +8913,12 @@ var EmsKernel = class extends FlowEngine {
8881
8913
  lastBill;
8882
8914
  /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
8883
8915
  tariffBasis;
8916
+ /** 선언된 요금 기준들 — 예정된 것을 포함한다(§`EnergyState.tariffBasisSchedule`). */
8917
+ tariffBasisSchedule;
8884
8918
  /** 마지막으로 받은 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. */
8885
8919
  generationPrice;
8920
+ /** 선언된 발전 단가들 — 예정된 것을 포함한다. */
8921
+ generationPriceSchedule;
8886
8922
  /**
8887
8923
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
8888
8924
  *
@@ -9519,6 +9555,7 @@ var EmsKernel = class extends FlowEngine {
9519
9555
  super.tick(dtMs);
9520
9556
  const at = this.nowMs();
9521
9557
  this.closeGenerationPeriodsDue(at);
9558
+ this.refreshDeclarationsInEffect(at);
9522
9559
  this.deriveLoad(at, dtMs);
9523
9560
  this.closeDue(at);
9524
9561
  }
@@ -9558,8 +9595,13 @@ var EmsKernel = class extends FlowEngine {
9558
9595
  }
9559
9596
  if (Number.isFinite(e.unclosedGenerationPeriods)) this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
9560
9597
  if (e.lastBill?.from && e.lastBill?.to) this.lastBill = { ...e.lastBill };
9598
+ if (Array.isArray(e.tariffBasisSchedule)) this.tariffBasisSchedule = e.tariffBasisSchedule.map((d) => ({ ...d }));
9599
+ if (Array.isArray(e.generationPriceSchedule)) this.generationPriceSchedule = e.generationPriceSchedule.map((d) => ({ ...d }));
9561
9600
  if (e.tariffBasis?.from && e.tariffBasis?.to) this.tariffBasis = { ...e.tariffBasis };
9562
9601
  if (e.generationPrice?.from && e.generationPrice?.to) this.generationPrice = { ...e.generationPrice };
9602
+ if (this.tariffBasisSchedule?.length || this.generationPriceSchedule?.length) {
9603
+ this.refreshDeclarationsInEffect(this.nowMs());
9604
+ }
9563
9605
  if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since) this.demandHigh = { ...e.demandHigh };
9564
9606
  }
9565
9607
  getSnapshot() {
@@ -9584,6 +9626,12 @@ var EmsKernel = class extends FlowEngine {
9584
9626
  ...this.lastBill ? { lastBill: { ...this.lastBill } } : {},
9585
9627
  ...this.tariffBasis ? { tariffBasis: { ...this.tariffBasis } } : {},
9586
9628
  ...this.generationPrice ? { generationPrice: { ...this.generationPrice } } : {},
9629
+ /*
9630
+ * **예정된 선언도 함께 낸다.** 지금 유효한 것만 이어받으면, 재기동한 뒤 9월이 되어도 8월 단가로
9631
+ * 계산한다 — 9월 선언이 어디에도 남아 있지 않기 때문이다. 오늘 이 부류를 세 번 겪었다.
9632
+ */
9633
+ ...this.tariffBasisSchedule?.length ? { tariffBasisSchedule: this.tariffBasisSchedule.map((d) => ({ ...d })) } : {},
9634
+ ...this.generationPriceSchedule?.length ? { generationPriceSchedule: this.generationPriceSchedule.map((d) => ({ ...d })) } : {},
9587
9635
  ...(() => {
9588
9636
  const r = this.generationRated();
9589
9637
  return r ? { generationRated: r } : {};
@@ -9596,6 +9644,26 @@ var EmsKernel = class extends FlowEngine {
9596
9644
  return { ...snap, energy };
9597
9645
  }
9598
9646
  };
9647
+ function putDeclaration(list, d) {
9648
+ const rest = (list ?? []).filter((x) => !(x.from === d.from && x.to === d.to));
9649
+ const next = [...rest, { ...d }].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
9650
+ return next.length > 24 ? next.slice(next.length - 24) : next;
9651
+ }
9652
+ function inEffectAt(list, atMs) {
9653
+ let best;
9654
+ let bestFrom = -Infinity;
9655
+ for (const d of list ?? []) {
9656
+ const from = Date.parse(String(d.from));
9657
+ const to = Date.parse(String(d.to));
9658
+ if (!Number.isFinite(from) || !Number.isFinite(to)) continue;
9659
+ if (atMs < from || atMs >= to) continue;
9660
+ if (from > bestFrom) {
9661
+ best = d;
9662
+ bestFrom = from;
9663
+ }
9664
+ }
9665
+ return best;
9666
+ }
9599
9667
 
9600
9668
  // src/event-journal.ts
9601
9669
  var DOMAIN_KERNELS = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.86",
3
+ "version": "0.7.88",
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": {