@operato/twin-kernel 0.7.71 → 0.7.72

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.
@@ -1885,6 +1885,17 @@ export interface MeterPointState {
1885
1885
  * 다르며, 다를 때 맞는 쪽은 계량기다).
1886
1886
  */
1887
1887
  kWhAtWindowStart?: number;
1888
+ /**
1889
+ * 밖에서 마감되어 온 **마지막 사용 구간** — 계량 표본이 없는 현장의 화면이 「지난 구간에 얼마 썼나」를
1890
+ * 말할 수 있게(§`ENERGY_EVENT.usagePeriod`).
1891
+ *
1892
+ * 우리가 마감한 구간(`kWhAtWindowStart` 로 뺀 값)과 **다른 값이다.** 더하지 않는다.
1893
+ */
1894
+ lastPeriodKWh?: number;
1895
+ lastPeriodFrom?: ISOTime;
1896
+ lastPeriodTo?: ISOTime;
1897
+ /** 그 값의 근거 — 계량인지, 공급자가 준 것인지, 청구서인지. */
1898
+ lastPeriodBasis?: ObservationBasis;
1888
1899
  }
1889
1900
  export interface DemandWindowState {
1890
1901
  startMs: number;
@@ -2009,6 +2020,13 @@ export interface EnergyState {
2009
2020
  hook: string;
2010
2021
  count: number;
2011
2022
  }[];
2023
+ /**
2024
+ * 마지막으로 받은 **청구서** — 공급자가 확정한 금액(§`ENERGY_EVENT.bill`).
2025
+ *
2026
+ * 우리가 계산한 금액을 덮지 않는다. 읽는 쪽이 둘을 견주어 어긋나면 그 사실을 낸다 — 우리 계산이
2027
+ * 틀렸다는 뜻이고, 사람이 찾아내지 않아도 드러나야 한다.
2028
+ */
2029
+ lastBill?: EnergyBillData;
2012
2030
  /**
2013
2031
  * 설비마다 **지금 기간의 발전 관측** — 기간 발전량을 확정할 때 쓰는 기준점.
2014
2032
  *
@@ -2424,6 +2442,29 @@ export declare const ENERGY_EVENT: {
2424
2442
  readonly equipment: "energy.equipment";
2425
2443
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
2426
2444
  readonly demandWindow: "energy.demand.window";
2445
+ /**
2446
+ * **마감된 사용 구간이 도착했다** — 우리가 마감한 것이 아니라 밖에서 확정되어 온 것이다.
2447
+ *
2448
+ * 계량 표본이 오지 않는 현장이 있다. 공급자의 조회나 계량 데이터 시스템이 「이 구간에 X 썼다」를
2449
+ * 이미 마감해 준다. 그 값을 적산(`energy.measured` 의 `kWh`)으로 보내면 커널이 그것을 누적으로
2450
+ * 읽고 차분을 또 구한다 — 차분의 차분이 되어 틀린다. 그래서 다른 사건이다.
2451
+ *
2452
+ * `demandWindow` 와 **합치지 않는다.** 하나는 우리가 표본에서 마감한 것이고 이것은 밖에서 온
2453
+ * 것이다. 두 회계를 더하면 같은 전기를 두 번 센다. 둘 다 있으면 읽는 쪽이 하나를 고르고 어느
2454
+ * 쪽인지 말한다.
2455
+ */
2456
+ readonly usagePeriod: "energy.usage.period";
2457
+ /**
2458
+ * **청구서가 도착했다** — 공급자가 확정한 금액.
2459
+ *
2460
+ * 우리가 계산한 금액(§`electricityCost`)을 덮지 않는다. 두 값은 다른 것이다: 청구는 사실이고 우리
2461
+ * 계산은 파생이다(구간 요금·예측·가정 비교에 쓴다). 계기 적산과 같은 규율이다 — 다를 때 맞는 쪽은
2462
+ * 그 회계를 가진 쪽이다.
2463
+ *
2464
+ * 어긋나면 **그 사실을 낸다.** 우리 계산이 틀렸다는 뜻이고, 그것을 사람이 찾아내지 않아도 드러나야
2465
+ * 한다(실제로 사람이 찾아낸 적이 있다 — 기본요금을 이 주기의 최대로 매기고 있었다).
2466
+ */
2467
+ readonly bill: "energy.bill";
2427
2468
  /**
2428
2469
  * 피크 경신 — 월 최대 수요가 갱신됐다.
2429
2470
  *
@@ -2608,6 +2649,53 @@ export interface EnergyGenerationPeriodData {
2608
2649
  /** 기간의 경계가 어디서 왔나 — 원본의 기준점(`declared`)이거나 현장의 시각 기준(`offset`). */
2609
2650
  boundary: 'declared' | 'offset';
2610
2651
  }
2652
+ /**
2653
+ * 값이 어디서 왔나 — **관측의 근거**.
2654
+ *
2655
+ * 같은 kWh 라도 계량기가 잰 것과 공급자 화면에서 긁어온 것은 무게가 다르다. 실제로 긁어온 값이
2656
+ * 망가져 있는 것을 본 적이 있다(다른 현장의 값이 섞이고, 세 자리 수가 한 자리로 왔다). 근거를 값과
2657
+ * 함께 두면 화면이 「이 수는 계량이 아니다」를 말할 수 있다.
2658
+ *
2659
+ * 커널은 이 값으로 판정하지 않는다 — 무엇을 믿을지는 현장이 정한다.
2660
+ */
2661
+ export declare const OBSERVATION_BASIS: readonly ["metered", "provider", "billed", "estimated"];
2662
+ export type ObservationBasis = (typeof OBSERVATION_BASIS)[number];
2663
+ /**
2664
+ * 마감된 사용 구간 — `ENERGY_EVENT.usagePeriod` 의 데이터.
2665
+ *
2666
+ * 적산이 아니라 **그 구간에 쓴 양**이다. 구간의 단가를 함께 실을 수 있다: 시간대·계절마다 단가가
2667
+ * 다른 현장에서 하나로 접으면 「피크를 깎아 얼마를 아끼나」가 사라진다.
2668
+ */
2669
+ export interface EnergyUsagePeriodData {
2670
+ meterId: string;
2671
+ from: ISOTime;
2672
+ to: ISOTime;
2673
+ /** 이 구간에 쓴 양(kWh). */
2674
+ kWh: number;
2675
+ /** 이 구간의 최대 수요(kW) — 원본이 함께 줄 때만. */
2676
+ maxKW?: number;
2677
+ /** 이 구간에 적용된 단가(1kWh 당). 통화는 `currency`. */
2678
+ unitPrice?: number;
2679
+ currency?: string;
2680
+ basis?: ObservationBasis;
2681
+ }
2682
+ /**
2683
+ * 청구서 — `ENERGY_EVENT.bill` 의 데이터.
2684
+ *
2685
+ * **커널의 요금 어휘로 받는다**(사용량 요금·기본요금·합계). 공급자마다 청구서의 칸 이름이 다르므로
2686
+ * 그 이름을 그대로 들이면 커널이 한 공급자의 모양에 묶인다. 옮기는 것은 커넥터의 일이다.
2687
+ */
2688
+ export interface EnergyBillData {
2689
+ from: ISOTime;
2690
+ to: ISOTime;
2691
+ /** 사용량 요금 · 기본요금 · 합계 — 청구서가 말한 값. 없는 것은 비운다(0 으로 채우지 않는다). */
2692
+ energyCharge?: number;
2693
+ demandCharge?: number;
2694
+ total?: number;
2695
+ currency?: string;
2696
+ /** 그 청구가 기본요금을 매긴 기준(kW) — 우리 선언과 다르면 선언이 낡은 것이다. */
2697
+ billingDemandKW?: number;
2698
+ }
2611
2699
  export interface EnergyDemandWindowData {
2612
2700
  /** 배전 구간(로케이션 id) 또는 현장 전체. */
2613
2701
  feederId?: string;
package/dist/contract.js CHANGED
@@ -1034,6 +1034,29 @@ export const ENERGY_EVENT = {
1034
1034
  equipment: 'energy.equipment',
1035
1035
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
1036
1036
  demandWindow: 'energy.demand.window',
1037
+ /**
1038
+ * **마감된 사용 구간이 도착했다** — 우리가 마감한 것이 아니라 밖에서 확정되어 온 것이다.
1039
+ *
1040
+ * 계량 표본이 오지 않는 현장이 있다. 공급자의 조회나 계량 데이터 시스템이 「이 구간에 X 썼다」를
1041
+ * 이미 마감해 준다. 그 값을 적산(`energy.measured` 의 `kWh`)으로 보내면 커널이 그것을 누적으로
1042
+ * 읽고 차분을 또 구한다 — 차분의 차분이 되어 틀린다. 그래서 다른 사건이다.
1043
+ *
1044
+ * `demandWindow` 와 **합치지 않는다.** 하나는 우리가 표본에서 마감한 것이고 이것은 밖에서 온
1045
+ * 것이다. 두 회계를 더하면 같은 전기를 두 번 센다. 둘 다 있으면 읽는 쪽이 하나를 고르고 어느
1046
+ * 쪽인지 말한다.
1047
+ */
1048
+ usagePeriod: 'energy.usage.period',
1049
+ /**
1050
+ * **청구서가 도착했다** — 공급자가 확정한 금액.
1051
+ *
1052
+ * 우리가 계산한 금액(§`electricityCost`)을 덮지 않는다. 두 값은 다른 것이다: 청구는 사실이고 우리
1053
+ * 계산은 파생이다(구간 요금·예측·가정 비교에 쓴다). 계기 적산과 같은 규율이다 — 다를 때 맞는 쪽은
1054
+ * 그 회계를 가진 쪽이다.
1055
+ *
1056
+ * 어긋나면 **그 사실을 낸다.** 우리 계산이 틀렸다는 뜻이고, 그것을 사람이 찾아내지 않아도 드러나야
1057
+ * 한다(실제로 사람이 찾아낸 적이 있다 — 기본요금을 이 주기의 최대로 매기고 있었다).
1058
+ */
1059
+ bill: 'energy.bill',
1037
1060
  /**
1038
1061
  * 피크 경신 — 월 최대 수요가 갱신됐다.
1039
1062
  *
@@ -1068,6 +1091,16 @@ export const ENERGY_EVENT = {
1068
1091
  */
1069
1092
  drSuggested: 'energy.dr.suggested'
1070
1093
  };
1094
+ /**
1095
+ * 값이 어디서 왔나 — **관측의 근거**.
1096
+ *
1097
+ * 같은 kWh 라도 계량기가 잰 것과 공급자 화면에서 긁어온 것은 무게가 다르다. 실제로 긁어온 값이
1098
+ * 망가져 있는 것을 본 적이 있다(다른 현장의 값이 섞이고, 세 자리 수가 한 자리로 왔다). 근거를 값과
1099
+ * 함께 두면 화면이 「이 수는 계량이 아니다」를 말할 수 있다.
1100
+ *
1101
+ * 커널은 이 값으로 판정하지 않는다 — 무엇을 믿을지는 현장이 정한다.
1102
+ */
1103
+ export const OBSERVATION_BASIS = ['metered', 'provider', 'billed', 'estimated'];
1071
1104
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
1072
1105
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
1073
1106
  export const CMD = {
@@ -111,6 +111,23 @@ export declare class EmsKernel extends FlowEngine {
111
111
  /** 재개점에서 되돌린다 — 담기지 않은 칸은 건드리지 않는다(빈 값으로 덮지 않는다). */
112
112
  restoreDomain(raw: unknown): void;
113
113
  apply(envelope: CanonicalEnvelope): void;
114
+ /**
115
+ * 밖에서 마감되어 온 사용 구간을 그 지점에 적는다.
116
+ *
117
+ * ── 수요 구간에 누적하지 않는다 ────────────────────────────────────────────
118
+ * 우리가 표본에서 마감하는 구간과 **다른 회계**다. 더하면 같은 전기를 두 번 센다. 여기서 하는 일은
119
+ * 「그 지점의 마지막 마감 구간」을 상태에 두는 것뿐이고, 구간들을 합하는 것은 저널을 읽는 쪽의 일이다
120
+ * (창을 맞춰 합해야 하므로).
121
+ *
122
+ * 모르는 지점이면 세어 둔다 — 조용히 버리면 「값이 왜 안 보이지」로만 남는다(계량과 같은 규율).
123
+ */
124
+ private applyUsagePeriod;
125
+ /**
126
+ * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
127
+ *
128
+ * 두 값은 다른 것이다: 청구는 사실이고 우리 계산은 파생이다. 어긋나면 읽는 쪽이 그것을 낸다.
129
+ */
130
+ private applyBill;
114
131
  /**
115
132
  * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
116
133
  *
@@ -180,6 +197,8 @@ export declare class EmsKernel extends FlowEngine {
180
197
  private generationPeriodObs;
181
198
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
182
199
  private unclosedGenerationPeriods;
200
+ /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
201
+ private lastBill?;
183
202
  private applyEquipmentEnergy;
184
203
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
185
204
  private unknownEquipmentReports;
@@ -269,8 +269,73 @@ export class EmsKernel extends FlowEngine {
269
269
  this.applyGenerated(envelope);
270
270
  return;
271
271
  }
272
+ if (envelope.eventType === ENERGY_EVENT.usagePeriod) {
273
+ this.applyUsagePeriod(envelope);
274
+ return;
275
+ }
276
+ if (envelope.eventType === ENERGY_EVENT.bill) {
277
+ this.applyBill(envelope);
278
+ return;
279
+ }
272
280
  super.apply(envelope);
273
281
  }
282
+ /**
283
+ * 밖에서 마감되어 온 사용 구간을 그 지점에 적는다.
284
+ *
285
+ * ── 수요 구간에 누적하지 않는다 ────────────────────────────────────────────
286
+ * 우리가 표본에서 마감하는 구간과 **다른 회계**다. 더하면 같은 전기를 두 번 센다. 여기서 하는 일은
287
+ * 「그 지점의 마지막 마감 구간」을 상태에 두는 것뿐이고, 구간들을 합하는 것은 저널을 읽는 쪽의 일이다
288
+ * (창을 맞춰 합해야 하므로).
289
+ *
290
+ * 모르는 지점이면 세어 둔다 — 조용히 버리면 「값이 왜 안 보이지」로만 남는다(계량과 같은 규율).
291
+ */
292
+ applyUsagePeriod(envelope) {
293
+ const d = envelope.data;
294
+ const id = String(d?.meterId ?? '').trim();
295
+ if (!id)
296
+ return;
297
+ const kWh = Number(d?.kWh);
298
+ if (!Number.isFinite(kWh))
299
+ return;
300
+ const point = this.points.get(id);
301
+ if (!point) {
302
+ /* 지점을 만들지 않는다 — 이 문은 「이 구간에 얼마」만 말하고 그 지점이 무엇인지 말하지 않는다. */
303
+ this.unknownEquipmentReports++;
304
+ return;
305
+ }
306
+ /* 늦게 온 옛 구간이 최신을 덮지 않게 — 끝 시각으로 가린다(계량 표본과 같은 규율). */
307
+ const toMs = Date.parse(String(d?.to ?? ''));
308
+ const priorToMs = Date.parse(String(point.lastPeriodTo ?? ''));
309
+ if (Number.isFinite(priorToMs) && Number.isFinite(toMs) && toMs < priorToMs)
310
+ return;
311
+ point.lastPeriodKWh = kWh;
312
+ if (d?.from)
313
+ point.lastPeriodFrom = d.from;
314
+ if (d?.to)
315
+ point.lastPeriodTo = d.to;
316
+ if (d?.basis)
317
+ point.lastPeriodBasis = d.basis;
318
+ this.revision++;
319
+ }
320
+ /**
321
+ * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
322
+ *
323
+ * 두 값은 다른 것이다: 청구는 사실이고 우리 계산은 파생이다. 어긋나면 읽는 쪽이 그것을 낸다.
324
+ */
325
+ applyBill(envelope) {
326
+ const d = envelope.data;
327
+ if (!d?.from || !d?.to)
328
+ return;
329
+ const toMs = Date.parse(String(d.to));
330
+ if (!Number.isFinite(toMs))
331
+ return;
332
+ const priorToMs = Date.parse(String(this.lastBill?.to ?? ''));
333
+ /* 지난 주기의 청구서가 늦게 와도 최신을 덮지 않는다. */
334
+ if (Number.isFinite(priorToMs) && toMs < priorToMs)
335
+ return;
336
+ this.lastBill = { ...d };
337
+ this.revision++;
338
+ }
274
339
  /**
275
340
  * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
276
341
  *
@@ -515,6 +580,8 @@ export class EmsKernel extends FlowEngine {
515
580
  generationPeriodObs = new Map();
516
581
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
517
582
  unclosedGenerationPeriods = 0;
583
+ /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
584
+ lastBill;
518
585
  applyEquipmentEnergy(envelope) {
519
586
  const d = envelope.data;
520
587
  const id = String(d?.equipmentId ?? '').trim();
@@ -1306,6 +1373,9 @@ export class EmsKernel extends FlowEngine {
1306
1373
  /* 못 마감한 수도 이어받는다 — 이어받지 않으면 재기동마다 0 으로 돌아가 문제가 사라진 것처럼 보인다. */
1307
1374
  if (Number.isFinite(e.unclosedGenerationPeriods))
1308
1375
  this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
1376
+ /* 청구서도 이어받는다 — 재기동마다 사라지면 우리 계산과 견줄 기준이 없어진다. */
1377
+ if (e.lastBill?.from && e.lastBill?.to)
1378
+ this.lastBill = { ...e.lastBill };
1309
1379
  }
1310
1380
  getSnapshot() {
1311
1381
  const snap = super.getSnapshot();
@@ -1330,6 +1400,7 @@ export class EmsKernel extends FlowEngine {
1330
1400
  }
1331
1401
  : {}),
1332
1402
  ...(this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {}),
1403
+ ...(this.lastBill ? { lastBill: { ...this.lastBill } } : {}),
1333
1404
  ...(contractKW !== undefined ? { contractKW } : {}),
1334
1405
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
1335
1406
  ...(this.flowRequests.size
@@ -37,6 +37,17 @@ export declare const EMS_PROPERTY: {
37
37
  readonly standbyKW: "power.standbyKW";
38
38
  /** 기본요금 단가 — 최대수요 1kW 당(청구 주기 기준). */
39
39
  readonly demandChargePerKW: "tariff.demandChargePerKW";
40
+ /**
41
+ * **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준. 계약전력과 다른 값이다.
42
+ *
43
+ * 기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다 — 지난 몇 달의 최고를 끌고 가거나,
44
+ * 약정 용량으로 매기거나, 사업자가 따로 정한다. 그 규칙은 나라와 계약마다 다르므로 커널이 계산하지
45
+ * 않고 **계산된 결과를 받는다.** 이 값이 있으면 기본요금이 조건부 파생이 아니라 사실이 된다.
46
+ *
47
+ * 계약전력(`contract.kW`)을 이 자리에 넣지 말 것. 넘었을 때의 뜻이 다르다 — 계약 초과는 약정
48
+ * 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
49
+ */
50
+ readonly billingDemandKW: "tariff.billingDemandKW";
40
51
  /** 사용량 단가 — 1kWh 당. */
41
52
  readonly energyChargePerKWh: "tariff.energyChargePerKWh";
42
53
  /** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
@@ -71,6 +71,17 @@ export const EMS_PROPERTY = {
71
71
  */
72
72
  /** 기본요금 단가 — 최대수요 1kW 당(청구 주기 기준). */
73
73
  demandChargePerKW: 'tariff.demandChargePerKW',
74
+ /**
75
+ * **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준. 계약전력과 다른 값이다.
76
+ *
77
+ * 기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다 — 지난 몇 달의 최고를 끌고 가거나,
78
+ * 약정 용량으로 매기거나, 사업자가 따로 정한다. 그 규칙은 나라와 계약마다 다르므로 커널이 계산하지
79
+ * 않고 **계산된 결과를 받는다.** 이 값이 있으면 기본요금이 조건부 파생이 아니라 사실이 된다.
80
+ *
81
+ * 계약전력(`contract.kW`)을 이 자리에 넣지 말 것. 넘었을 때의 뜻이 다르다 — 계약 초과는 약정
82
+ * 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
83
+ */
84
+ billingDemandKW: 'tariff.billingDemandKW',
74
85
  /** 사용량 단가 — 1kWh 당. */
75
86
  energyChargePerKWh: 'tariff.energyChargePerKWh',
76
87
  /** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
@@ -141,6 +152,11 @@ export const EMS_PROPERTY_SPEC = {
141
152
  [EMS_PROPERTY.ratedKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while running, in kW.' },
142
153
  [EMS_PROPERTY.standbyKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while idle, in kW.' },
143
154
  [EMS_PROPERTY.demandChargePerKW]: { dataType: 'xs:double', note: 'demand charge per kW of billing-period peak, in the declared currency.' },
155
+ [EMS_PROPERTY.billingDemandKW]: {
156
+ uom: 'kW',
157
+ dataType: 'xs:double',
158
+ note: 'billing demand in kW — the basis the demand charge is actually billed on (often a rolling 12-month peak). Not the contracted power.'
159
+ },
144
160
  [EMS_PROPERTY.energyChargePerKWh]: { dataType: 'xs:double', note: 'energy charge per kWh, in the declared currency.' },
145
161
  [EMS_PROPERTY.currency]: { dataType: 'xs:string', note: 'ISO 4217 currency code, e.g. USD or KRW.' },
146
162
  [EMS_PROPERTY.capacityKWh]: { uom: 'kWh', dataType: 'xs:double', note: 'usable energy of the storage, in kWh.' },
@@ -248,6 +248,24 @@ export declare function energyOfWindows(windows: readonly {
248
248
  export interface TariffDeclaration {
249
249
  /** 최대수요 1kW 당 기본요금. */
250
250
  demandChargePerKW?: number;
251
+ /**
252
+ * **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준.
253
+ *
254
+ * ── 왜 최대수요와 다른 자리인가 (2026-08-29) ─────────────────────────────
255
+ * **기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다.** 지난 몇 달의 최고를 끌고 가거나,
256
+ * 약정한 용량으로 매기거나, 사업자가 따로 정한 기준을 쓴다. 그 규칙은 나라와 계약마다 다르므로
257
+ * **커널이 그것을 계산하지 않는다** — 계산된 결과를 선언으로 받는다.
258
+ *
259
+ * 그것을 확인한 실측 하나(2026-08-29, 어느 고압 현장): 청구 기준 1,650kW · 이 주기의 최대 600kW ·
260
+ * 기본요금 13,728,000. 이 주기의 최대로 계산하면 2.75배 어긋난다.
261
+ *
262
+ * 이 값이 있으면 기본요금은 **조건부 파생이 아니라 사실**이 된다(§`demandBasis`). 없으면 예전처럼
263
+ * 「이 창의 피크가 그 주기의 최고로 남는다면」이라는 조건을 달고 낸다.
264
+ *
265
+ * **계약전력(`contract.kW`)과 다른 값이다.** 계약전력은 약정이고 이것은 청구 기준이다. 넘었을 때
266
+ * 뜻도 다르다 — 계약 초과는 약정 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
267
+ */
268
+ billingDemandKW?: number;
251
269
  /** 1kWh 당 사용량 요금. */
252
270
  energyChargePerKWh?: number;
253
271
  /** ISO 4217 통화 코드. 없으면 금액에 단위를 붙일 수 없다. */
@@ -276,9 +294,19 @@ export type ElectricityCost = {
276
294
  /** 위 둘의 합. 둘 중 하나만 있으면 그 하나다(없는 쪽을 0 으로 세지 않는다). */
277
295
  total?: number;
278
296
  currency?: string;
279
- /** 기본요금이 성립하는 조건 — 창의 피크가 청구 주기의 최고일 때. */
280
- demandBasis?: 'window-peak-as-period-peak';
281
- /** 세우지 않은 요금 요소 — 소비처가 「이 값이 청구서가 아니다」를 말할 수 있게. */
297
+ /**
298
+ * 기본요금이 무엇 위에 섰나.
299
+ *
300
+ * 'billing-demand' 요금적용전력 선언 위 — 조건이 없다(청구 기준 그대로)
301
+ * 'window-peak-as-period-peak' 창의 피크가 청구 주기의 최고일 때만 성립하는 조건부 값
302
+ */
303
+ demandBasis?: 'billing-demand' | 'window-peak-as-period-peak';
304
+ /**
305
+ * 세우지 않은 요금 요소 — 소비처가 「이 값이 청구서가 아니다」를 말할 수 있게.
306
+ *
307
+ * **이 계산에서 실제로 빠진 것만** 담는다(2026-08-29). 예전에는 고정 목록이었는데, 그러면 선언이
308
+ * 채워져도 「세우지 않았다」가 그대로 남아 화면이 값을 실제보다 약하게 말한다.
309
+ */
282
310
  notModeled: readonly string[];
283
311
  } | {
284
312
  reason: 'no-tariff';
@@ -287,4 +315,11 @@ export declare function electricityCost(input: {
287
315
  kWh?: number;
288
316
  peakKW?: number;
289
317
  tariff?: TariffDeclaration;
318
+ /**
319
+ * 구간마다 제 단가로 계산한 사용량 요금 — 시간대·계절 요금이 오는 현장에서 소비처가 미리 더해 넘긴다.
320
+ *
321
+ * 넘어오면 단일 단가(`energyChargePerKWh`)를 쓰지 않는다. 새벽 87.3원과 낮 222.3원을 하나로 접으면
322
+ * 「피크를 깎아 얼마를 아끼나」가 사라진다 — 그 차이 위에 서는 답이기 때문이다.
323
+ */
324
+ energyChargeFromPeriods?: number;
290
325
  }): ElectricityCost;
@@ -250,22 +250,45 @@ export function electricityCost(input) {
250
250
  const t = input.tariff;
251
251
  const eRate = Number(t?.energyChargePerKWh);
252
252
  const dRate = Number(t?.demandChargePerKW);
253
+ const billingKW = Number(t?.billingDemandKW);
253
254
  const hasE = Number.isFinite(eRate) && eRate > 0;
254
255
  const hasD = Number.isFinite(dRate) && dRate > 0;
256
+ const hasBilling = Number.isFinite(billingKW) && billingKW > 0;
257
+ const priced = Number(input.energyChargeFromPeriods);
258
+ const hasPriced = Number.isFinite(priced) && priced >= 0;
255
259
  /* 단가가 하나도 없으면 금액을 만들지 않는다 — 이유를 낸다(빈 값과 0 원을 구별할 수 있게). */
256
- if (!hasE && !hasD)
260
+ if (!hasE && !hasD && !hasPriced)
257
261
  return { reason: 'no-tariff' };
258
262
  const kWh = Number(input.kWh);
259
263
  const peakKW = Number(input.peakKW);
260
- const energyCharge = hasE && Number.isFinite(kWh) ? kWh * eRate : undefined;
261
- const demandCharge = hasD && Number.isFinite(peakKW) ? peakKW * dRate : undefined;
264
+ /* 구간별로 매겨진 값이 있으면 그것이 이긴다 — 그 쪽이 실제 단가를 알고 있다. */
265
+ const energyCharge = hasPriced ? priced : hasE && Number.isFinite(kWh) ? kWh * eRate : undefined;
266
+ /* 기본요금은 **요금적용전력이 있으면 그것으로** 매긴다. 없을 때만 창의 피크로 조건부 값을 낸다. */
267
+ const demandBase = hasBilling ? billingKW : peakKW;
268
+ const demandCharge = hasD && Number.isFinite(demandBase) ? demandBase * dRate : undefined;
262
269
  /* 반올림하지 않는다 — 표현은 화면의 일이고, 여기서 깎으면 합과 부분이 어긋난다. */
263
270
  const total = energyCharge === undefined && demandCharge === undefined ? undefined : (energyCharge ?? 0) + (demandCharge ?? 0);
271
+ /*
272
+ * 「세우지 않은 것」을 **이번 계산 기준으로** 센다. 선언이 채워진 요소는 빼야 화면이 값을 실제보다
273
+ * 약하게 말하지 않는다.
274
+ */
275
+ const notModeled = NOT_MODELED.filter(k => {
276
+ if ((k === 'tou' || k === 'seasonal') && hasPriced)
277
+ return false; // 구간마다 제 단가로 매겨졌다
278
+ if (k === 'ratchet' && hasBilling)
279
+ return false; // 요금적용전력이 곧 래칫의 결과다
280
+ return true;
281
+ });
264
282
  return {
265
283
  ...(energyCharge !== undefined ? { energyCharge } : {}),
266
- ...(demandCharge !== undefined ? { demandCharge, demandBasis: 'window-peak-as-period-peak' } : {}),
284
+ ...(demandCharge !== undefined
285
+ ? {
286
+ demandCharge,
287
+ demandBasis: (hasBilling ? 'billing-demand' : 'window-peak-as-period-peak')
288
+ }
289
+ : {}),
267
290
  ...(total !== undefined ? { total } : {}),
268
291
  ...(t?.currency ? { currency: String(t.currency) } : {}),
269
- notModeled: NOT_MODELED
292
+ notModeled
270
293
  };
271
294
  }
@@ -98,3 +98,28 @@ export declare function isEnergyGenerationRecord(record: unknown): boolean;
98
98
  * (`EnergyEquipmentRecord.generatedKW`).
99
99
  */
100
100
  export declare function ingestEnergyGenerationRecords(records: EnergyGenerationRecord | EnergyGenerationRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
101
+ /** 마감된 사용 구간 — 커넥터가 맞추는 모양. */
102
+ export interface EnergyUsagePeriodRecord {
103
+ meterId: string;
104
+ from: string;
105
+ to: string;
106
+ kWh: number;
107
+ maxKW?: number;
108
+ unitPrice?: number;
109
+ currency?: string;
110
+ basis?: string;
111
+ }
112
+ export declare function isEnergyUsagePeriodRecord(record: unknown): boolean;
113
+ /** 청구서 — 커넥터가 커널의 요금 어휘로 옮겨 보낸다. */
114
+ export interface EnergyBillRecord {
115
+ from: string;
116
+ to: string;
117
+ energyCharge?: number;
118
+ demandCharge?: number;
119
+ total?: number;
120
+ currency?: string;
121
+ billingDemandKW?: number;
122
+ }
123
+ export declare function isEnergyBillRecord(record: unknown): boolean;
124
+ export declare function ingestEnergyUsagePeriodRecords(records: EnergyUsagePeriodRecord | EnergyUsagePeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
125
+ export declare function ingestEnergyBillRecords(records: EnergyBillRecord | EnergyBillRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
@@ -21,7 +21,7 @@
21
21
  * 값(`kW`)이 없는 표본은 **거부하지 않는다**: 계량기가 살아 있다는 사실 자체가 관측이고, 커널이
22
22
  * 「받았지만 부하를 못 읽었다」를 구별해 낸다(`observedAbsence: 'no-load-samples'`).
23
23
  */
24
- import { ENERGY_EVENT } from "./contract.js";
24
+ import { ENERGY_EVENT, OBSERVATION_BASIS } from "./contract.js";
25
25
  /** 이 레코드가 에너지 표본인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게). */
26
26
  export function isEnergyRecord(record) {
27
27
  if (!record || typeof record !== 'object')
@@ -315,3 +315,152 @@ export function ingestEnergyGenerationRecords(records, opts) {
315
315
  }
316
316
  return { accepted, rejected };
317
317
  }
318
+ export function isEnergyUsagePeriodRecord(record) {
319
+ const r = record;
320
+ return !!r && typeof r === 'object' && r.meterId !== undefined && r.from !== undefined && r.to !== undefined && r.kWh !== undefined;
321
+ }
322
+ export function isEnergyBillRecord(record) {
323
+ const r = record;
324
+ if (!r || typeof r !== 'object')
325
+ return false;
326
+ if (r.from === undefined || r.to === undefined)
327
+ return false;
328
+ return r.energyCharge !== undefined || r.demandCharge !== undefined || r.total !== undefined;
329
+ }
330
+ /** 두 시각을 읽고 순서를 본다 — 뒤집힌 구간은 받지 않는다(합이 음수가 된다). */
331
+ function readSpan(r, errors) {
332
+ const from = String(r?.from ?? '').trim();
333
+ const to = String(r?.to ?? '').trim();
334
+ const fromMs = Date.parse(from);
335
+ const toMs = Date.parse(to);
336
+ if (!from || !Number.isFinite(fromMs))
337
+ errors.push(`from 을 시각으로 읽을 수 없다: ${JSON.stringify(r?.from)}`);
338
+ if (!to || !Number.isFinite(toMs))
339
+ errors.push(`to 를 시각으로 읽을 수 없다: ${JSON.stringify(r?.to)}`);
340
+ if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
341
+ errors.push(`구간이 뒤집혔거나 길이가 없다(${from} → ${to}) — 어느 쪽이 틀렸는지 우리가 정할 수 없다`);
342
+ }
343
+ return errors.length ? undefined : { from, to };
344
+ }
345
+ /** 0 이상의 수 하나 — 없으면 `undefined`, 값인데 읽을 수 없으면 오류. */
346
+ function readAmount(raw, name, errors) {
347
+ if (raw === undefined || raw === null || String(raw).trim() === '')
348
+ return undefined;
349
+ const v = Number(raw);
350
+ if (!Number.isFinite(v)) {
351
+ errors.push(`${name} 를 수로 읽을 수 없다: ${JSON.stringify(raw)}`);
352
+ return undefined;
353
+ }
354
+ if (v < 0) {
355
+ errors.push(`${name} 가 음수다(${v})`);
356
+ return undefined;
357
+ }
358
+ return v;
359
+ }
360
+ export function ingestEnergyUsagePeriodRecords(records, opts) {
361
+ const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
362
+ const accepted = [];
363
+ const rejected = [];
364
+ let seq = 0;
365
+ for (const r of list) {
366
+ const errors = [];
367
+ const meterId = String(r?.meterId ?? '').trim();
368
+ if (!meterId)
369
+ errors.push('meterId 가 없다 — 어느 지점이 쓴 것인지 지어낼 수 없다');
370
+ const span = readSpan(r, errors);
371
+ const rawKWh = r?.kWh;
372
+ let kWh;
373
+ if (rawKWh === undefined || rawKWh === null || String(rawKWh).trim() === '') {
374
+ errors.push('kWh 가 없다 — 이 문이 받는 값은 그 구간에 쓴 양이다');
375
+ }
376
+ else {
377
+ kWh = readAmount(rawKWh, 'kWh', errors);
378
+ }
379
+ const maxKW = readAmount(r?.maxKW, 'maxKW', errors);
380
+ const unitPrice = readAmount(r?.unitPrice, 'unitPrice', errors);
381
+ /* 단가를 실었으면 통화도 있어야 한다 — 단위 없는 금액은 다른 값과 더할 수 없다. */
382
+ const currency = String(r?.currency ?? '').trim() || undefined;
383
+ if (unitPrice !== undefined && !currency)
384
+ errors.push('unitPrice 를 실었는데 currency 가 없다 — 단위 없는 금액은 더할 수 없다');
385
+ /* 근거는 아는 목록 안에서만 — 모르는 값을 받으면 화면이 그것을 근거로 말한다. */
386
+ let basis;
387
+ const rawBasis = r?.basis;
388
+ if (rawBasis !== undefined && rawBasis !== null && String(rawBasis).trim()) {
389
+ const text = String(rawBasis).trim();
390
+ if (!OBSERVATION_BASIS.includes(text)) {
391
+ errors.push(`basis 가 아는 값이 아니다(${OBSERVATION_BASIS.join('·')}): ${JSON.stringify(rawBasis)}`);
392
+ }
393
+ else
394
+ basis = text;
395
+ }
396
+ if (errors.length || !span || kWh === undefined) {
397
+ rejected.push({ record: r, errors });
398
+ continue;
399
+ }
400
+ const data = {
401
+ meterId,
402
+ from: span.from,
403
+ to: span.to,
404
+ kWh,
405
+ ...(maxKW !== undefined ? { maxKW } : {}),
406
+ ...(unitPrice !== undefined ? { unitPrice } : {}),
407
+ ...(currency ? { currency } : {}),
408
+ ...(basis ? { basis } : {})
409
+ };
410
+ accepted.push({
411
+ eventId: `${opts.tenantId}-usage-period-${++seq}`,
412
+ eventType: ENERGY_EVENT.usagePeriod,
413
+ /* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
414
+ eventTime: span.to,
415
+ tenantId: opts.tenantId,
416
+ data
417
+ });
418
+ }
419
+ return { accepted, rejected };
420
+ }
421
+ export function ingestEnergyBillRecords(records, opts) {
422
+ const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
423
+ const accepted = [];
424
+ const rejected = [];
425
+ let seq = 0;
426
+ for (const r of list) {
427
+ const errors = [];
428
+ const span = readSpan(r, errors);
429
+ const energyCharge = readAmount(r?.energyCharge, 'energyCharge', errors);
430
+ const demandCharge = readAmount(r?.demandCharge, 'demandCharge', errors);
431
+ const total = readAmount(r?.total, 'total', errors);
432
+ const billingDemandKW = readAmount(r?.billingDemandKW, 'billingDemandKW', errors);
433
+ if (energyCharge === undefined && demandCharge === undefined && total === undefined) {
434
+ errors.push('금액이 하나도 없다 — 적을 사실이 없는 청구서는 받지 않는다');
435
+ }
436
+ /* 금액을 실었으면 통화가 있어야 한다. 없으면 그 수는 다른 수와 더할 수 없다. */
437
+ const currency = String(r?.currency ?? '').trim() || undefined;
438
+ if (!currency)
439
+ errors.push('currency 가 없다 — 단위 없는 금액은 다른 금액과 더할 수 없다');
440
+ /*
441
+ * 합계가 부분들과 다른 것은 **거부하지 않는다.** 세금·역률 요금·최소 청구액처럼 우리가 세지 않는
442
+ * 항목이 청구서에 있을 수 있다. 그 차이는 사실이고, 읽는 쪽이 그것을 보고 판단한다.
443
+ */
444
+ if (errors.length || !span) {
445
+ rejected.push({ record: r, errors });
446
+ continue;
447
+ }
448
+ const data = {
449
+ from: span.from,
450
+ to: span.to,
451
+ ...(energyCharge !== undefined ? { energyCharge } : {}),
452
+ ...(demandCharge !== undefined ? { demandCharge } : {}),
453
+ ...(total !== undefined ? { total } : {}),
454
+ ...(currency ? { currency } : {}),
455
+ ...(billingDemandKW !== undefined ? { billingDemandKW } : {})
456
+ };
457
+ accepted.push({
458
+ eventId: `${opts.tenantId}-bill-${++seq}`,
459
+ eventType: ENERGY_EVENT.bill,
460
+ eventTime: span.to,
461
+ tenantId: opts.tenantId,
462
+ data
463
+ });
464
+ }
465
+ return { accepted, rejected };
466
+ }
package/dist/index.d.ts CHANGED
@@ -30,10 +30,10 @@ export { WmsKernel } from './kernel.ts';
30
30
  export { YmsKernel } from './yms-kernel.ts';
31
31
  export { MesKernel } from './mes-kernel.ts';
32
32
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from './ems-kernel.ts';
33
- export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord } from './energy-ingest.ts';
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord } from './energy-ingest.ts';
34
34
  export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from './operational-ingest.ts';
35
35
  export type { OperationalKind, OperationalRecord, OperationalIngestOptions } from './operational-ingest.ts';
36
36
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from './energy-attribution.ts';
37
37
  export type { AttributionBasis, AttributionResult, ElectricityCost, EnergyConsumer, EnergyPool, EnergyShare, IntensityInput, IntensityResult, IntensityDenominator, TariffDeclaration, WeightKind, WindowedEnergy } from './energy-attribution.ts';
38
- export type { EnergyRecord, EnergyEquipmentRecord, EnergyGenerationRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
38
+ export type { EnergyRecord, EnergyEquipmentRecord, EnergyGenerationRecord, EnergyUsagePeriodRecord, EnergyBillRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
39
39
  export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { WmsKernel } from "./kernel.js";
30
30
  export { YmsKernel } from "./yms-kernel.js";
31
31
  export { MesKernel } from "./mes-kernel.js";
32
32
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from "./ems-kernel.js";
33
- export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord } from "./energy-ingest.js";
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord } from "./energy-ingest.js";
34
34
  /* 운영 사실의 문 — 리듀서가 다루는 여섯이 들어오는 자리(미러가 시뮬보다 가난하지 않게). */
35
35
  export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from "./operational-ingest.js";
36
36
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from "./energy-attribution.js";
@@ -56,6 +56,7 @@ __export(index_exports, {
56
56
  MES_TYPES: () => MES_TYPES,
57
57
  METER_DIRECTION: () => METER_DIRECTION,
58
58
  MesKernel: () => MesKernel,
59
+ OBSERVATION_BASIS: () => OBSERVATION_BASIS,
59
60
  OPERATION_PROPERTY: () => OPERATION_PROPERTY,
60
61
  OP_EVENT: () => OP_EVENT,
61
62
  OP_PARAM: () => OP_PARAM,
@@ -124,16 +125,20 @@ __export(index_exports, {
124
125
  inWorkCalendar: () => inWorkCalendar,
125
126
  inWorkCalendarAt: () => inWorkCalendarAt,
126
127
  ingest: () => ingest,
128
+ ingestEnergyBillRecords: () => ingestEnergyBillRecords,
127
129
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
128
130
  ingestEnergyGenerationRecords: () => ingestEnergyGenerationRecords,
129
131
  ingestEnergyRecords: () => ingestEnergyRecords,
132
+ ingestEnergyUsagePeriodRecords: () => ingestEnergyUsagePeriodRecords,
130
133
  ingestMasterData: () => ingestMasterData,
131
134
  ingestOperationalRecords: () => ingestOperationalRecords,
132
135
  isAggregationRecord: () => isAggregationRecord,
133
136
  isElectricalLocationType: () => isElectricalLocationType,
137
+ isEnergyBillRecord: () => isEnergyBillRecord,
134
138
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
135
139
  isEnergyGenerationRecord: () => isEnergyGenerationRecord,
136
140
  isEnergyRecord: () => isEnergyRecord,
141
+ isEnergyUsagePeriodRecord: () => isEnergyUsagePeriodRecord,
137
142
  isEpcisEventType: () => isEpcisEventType,
138
143
  isEquipmentLevel: () => isEquipmentLevel,
139
144
  isMasterDataRecord: () => isMasterDataRecord,
@@ -692,6 +697,29 @@ var ENERGY_EVENT = {
692
697
  equipment: "energy.equipment",
693
698
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
694
699
  demandWindow: "energy.demand.window",
700
+ /**
701
+ * **마감된 사용 구간이 도착했다** — 우리가 마감한 것이 아니라 밖에서 확정되어 온 것이다.
702
+ *
703
+ * 계량 표본이 오지 않는 현장이 있다. 공급자의 조회나 계량 데이터 시스템이 「이 구간에 X 썼다」를
704
+ * 이미 마감해 준다. 그 값을 적산(`energy.measured` 의 `kWh`)으로 보내면 커널이 그것을 누적으로
705
+ * 읽고 차분을 또 구한다 — 차분의 차분이 되어 틀린다. 그래서 다른 사건이다.
706
+ *
707
+ * `demandWindow` 와 **합치지 않는다.** 하나는 우리가 표본에서 마감한 것이고 이것은 밖에서 온
708
+ * 것이다. 두 회계를 더하면 같은 전기를 두 번 센다. 둘 다 있으면 읽는 쪽이 하나를 고르고 어느
709
+ * 쪽인지 말한다.
710
+ */
711
+ usagePeriod: "energy.usage.period",
712
+ /**
713
+ * **청구서가 도착했다** — 공급자가 확정한 금액.
714
+ *
715
+ * 우리가 계산한 금액(§`electricityCost`)을 덮지 않는다. 두 값은 다른 것이다: 청구는 사실이고 우리
716
+ * 계산은 파생이다(구간 요금·예측·가정 비교에 쓴다). 계기 적산과 같은 규율이다 — 다를 때 맞는 쪽은
717
+ * 그 회계를 가진 쪽이다.
718
+ *
719
+ * 어긋나면 **그 사실을 낸다.** 우리 계산이 틀렸다는 뜻이고, 그것을 사람이 찾아내지 않아도 드러나야
720
+ * 한다(실제로 사람이 찾아낸 적이 있다 — 기본요금을 이 주기의 최대로 매기고 있었다).
721
+ */
722
+ bill: "energy.bill",
695
723
  /**
696
724
  * 피크 경신 — 월 최대 수요가 갱신됐다.
697
725
  *
@@ -726,6 +754,7 @@ var ENERGY_EVENT = {
726
754
  */
727
755
  drSuggested: "energy.dr.suggested"
728
756
  };
757
+ var OBSERVATION_BASIS = ["metered", "provider", "billed", "estimated"];
729
758
  var CMD = {
730
759
  orderHold: "order.hold",
731
760
  orderResume: "order.resume",
@@ -7464,6 +7493,17 @@ var EMS_PROPERTY = {
7464
7493
  */
7465
7494
  /** 기본요금 단가 — 최대수요 1kW 당(청구 주기 기준). */
7466
7495
  demandChargePerKW: "tariff.demandChargePerKW",
7496
+ /**
7497
+ * **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준. 계약전력과 다른 값이다.
7498
+ *
7499
+ * 기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다 — 지난 몇 달의 최고를 끌고 가거나,
7500
+ * 약정 용량으로 매기거나, 사업자가 따로 정한다. 그 규칙은 나라와 계약마다 다르므로 커널이 계산하지
7501
+ * 않고 **계산된 결과를 받는다.** 이 값이 있으면 기본요금이 조건부 파생이 아니라 사실이 된다.
7502
+ *
7503
+ * 계약전력(`contract.kW`)을 이 자리에 넣지 말 것. 넘었을 때의 뜻이 다르다 — 계약 초과는 약정
7504
+ * 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
7505
+ */
7506
+ billingDemandKW: "tariff.billingDemandKW",
7467
7507
  /** 사용량 단가 — 1kWh 당. */
7468
7508
  energyChargePerKWh: "tariff.energyChargePerKWh",
7469
7509
  /** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
@@ -7532,6 +7572,11 @@ var EMS_PROPERTY_SPEC = {
7532
7572
  [EMS_PROPERTY.ratedKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while running, in kW." },
7533
7573
  [EMS_PROPERTY.standbyKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while idle, in kW." },
7534
7574
  [EMS_PROPERTY.demandChargePerKW]: { dataType: "xs:double", note: "demand charge per kW of billing-period peak, in the declared currency." },
7575
+ [EMS_PROPERTY.billingDemandKW]: {
7576
+ uom: "kW",
7577
+ dataType: "xs:double",
7578
+ note: "billing demand in kW \u2014 the basis the demand charge is actually billed on (often a rolling 12-month peak). Not the contracted power."
7579
+ },
7535
7580
  [EMS_PROPERTY.energyChargePerKWh]: { dataType: "xs:double", note: "energy charge per kWh, in the declared currency." },
7536
7581
  [EMS_PROPERTY.currency]: { dataType: "xs:string", note: "ISO 4217 currency code, e.g. USD or KRW." },
7537
7582
  [EMS_PROPERTY.capacityKWh]: { uom: "kWh", dataType: "xs:double", note: "usable energy of the storage, in kWh." },
@@ -7897,8 +7942,61 @@ var EmsKernel = class extends FlowEngine {
7897
7942
  this.applyGenerated(envelope);
7898
7943
  return;
7899
7944
  }
7945
+ if (envelope.eventType === ENERGY_EVENT.usagePeriod) {
7946
+ this.applyUsagePeriod(envelope);
7947
+ return;
7948
+ }
7949
+ if (envelope.eventType === ENERGY_EVENT.bill) {
7950
+ this.applyBill(envelope);
7951
+ return;
7952
+ }
7900
7953
  super.apply(envelope);
7901
7954
  }
7955
+ /**
7956
+ * 밖에서 마감되어 온 사용 구간을 그 지점에 적는다.
7957
+ *
7958
+ * ── 수요 구간에 누적하지 않는다 ────────────────────────────────────────────
7959
+ * 우리가 표본에서 마감하는 구간과 **다른 회계**다. 더하면 같은 전기를 두 번 센다. 여기서 하는 일은
7960
+ * 「그 지점의 마지막 마감 구간」을 상태에 두는 것뿐이고, 구간들을 합하는 것은 저널을 읽는 쪽의 일이다
7961
+ * (창을 맞춰 합해야 하므로).
7962
+ *
7963
+ * 모르는 지점이면 세어 둔다 — 조용히 버리면 「값이 왜 안 보이지」로만 남는다(계량과 같은 규율).
7964
+ */
7965
+ applyUsagePeriod(envelope) {
7966
+ const d = envelope.data;
7967
+ const id = String(d?.meterId ?? "").trim();
7968
+ if (!id) return;
7969
+ const kWh = Number(d?.kWh);
7970
+ if (!Number.isFinite(kWh)) return;
7971
+ const point = this.points.get(id);
7972
+ if (!point) {
7973
+ this.unknownEquipmentReports++;
7974
+ return;
7975
+ }
7976
+ const toMs = Date.parse(String(d?.to ?? ""));
7977
+ const priorToMs = Date.parse(String(point.lastPeriodTo ?? ""));
7978
+ if (Number.isFinite(priorToMs) && Number.isFinite(toMs) && toMs < priorToMs) return;
7979
+ point.lastPeriodKWh = kWh;
7980
+ if (d?.from) point.lastPeriodFrom = d.from;
7981
+ if (d?.to) point.lastPeriodTo = d.to;
7982
+ if (d?.basis) point.lastPeriodBasis = d.basis;
7983
+ this.revision++;
7984
+ }
7985
+ /**
7986
+ * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
7987
+ *
7988
+ * 두 값은 다른 것이다: 청구는 사실이고 우리 계산은 파생이다. 어긋나면 읽는 쪽이 그것을 낸다.
7989
+ */
7990
+ applyBill(envelope) {
7991
+ const d = envelope.data;
7992
+ if (!d?.from || !d?.to) return;
7993
+ const toMs = Date.parse(String(d.to));
7994
+ if (!Number.isFinite(toMs)) return;
7995
+ const priorToMs = Date.parse(String(this.lastBill?.to ?? ""));
7996
+ if (Number.isFinite(priorToMs) && toMs < priorToMs) return;
7997
+ this.lastBill = { ...d };
7998
+ this.revision++;
7999
+ }
7902
8000
  /**
7903
8001
  * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
7904
8002
  *
@@ -8058,6 +8156,8 @@ var EmsKernel = class extends FlowEngine {
8058
8156
  generationPeriodObs = /* @__PURE__ */ new Map();
8059
8157
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
8060
8158
  unclosedGenerationPeriods = 0;
8159
+ /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
8160
+ lastBill;
8061
8161
  applyEquipmentEnergy(envelope) {
8062
8162
  const d = envelope.data;
8063
8163
  const id = String(d?.equipmentId ?? "").trim();
@@ -8610,6 +8710,7 @@ var EmsKernel = class extends FlowEngine {
8610
8710
  });
8611
8711
  }
8612
8712
  if (Number.isFinite(e.unclosedGenerationPeriods)) this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
8713
+ if (e.lastBill?.from && e.lastBill?.to) this.lastBill = { ...e.lastBill };
8613
8714
  }
8614
8715
  getSnapshot() {
8615
8716
  const snap = super.getSnapshot();
@@ -8630,6 +8731,7 @@ var EmsKernel = class extends FlowEngine {
8630
8731
  generationPeriods: [...this.generationPeriodObs.entries()].map(([equipmentId, o]) => ({ equipmentId, ...o }))
8631
8732
  } : {},
8632
8733
  ...this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {},
8734
+ ...this.lastBill ? { lastBill: { ...this.lastBill } } : {},
8633
8735
  ...contractKW !== void 0 ? { contractKW } : {},
8634
8736
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
8635
8737
  ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
@@ -9634,6 +9736,135 @@ function ingestEnergyGenerationRecords(records, opts) {
9634
9736
  }
9635
9737
  return { accepted, rejected };
9636
9738
  }
9739
+ function isEnergyUsagePeriodRecord(record) {
9740
+ const r = record;
9741
+ return !!r && typeof r === "object" && r.meterId !== void 0 && r.from !== void 0 && r.to !== void 0 && r.kWh !== void 0;
9742
+ }
9743
+ function isEnergyBillRecord(record) {
9744
+ const r = record;
9745
+ if (!r || typeof r !== "object") return false;
9746
+ if (r.from === void 0 || r.to === void 0) return false;
9747
+ return r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0;
9748
+ }
9749
+ function readSpan(r, errors) {
9750
+ const from = String(r?.from ?? "").trim();
9751
+ const to = String(r?.to ?? "").trim();
9752
+ const fromMs = Date.parse(from);
9753
+ const toMs = Date.parse(to);
9754
+ if (!from || !Number.isFinite(fromMs)) errors.push(`from \uC744 \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.from)}`);
9755
+ if (!to || !Number.isFinite(toMs)) errors.push(`to \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.to)}`);
9756
+ if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
9757
+ errors.push(`\uAD6C\uAC04\uC774 \uB4A4\uC9D1\uD614\uAC70\uB098 \uAE38\uC774\uAC00 \uC5C6\uB2E4(${from} \u2192 ${to}) \u2014 \uC5B4\uB290 \uCABD\uC774 \uD2C0\uB838\uB294\uC9C0 \uC6B0\uB9AC\uAC00 \uC815\uD560 \uC218 \uC5C6\uB2E4`);
9758
+ }
9759
+ return errors.length ? void 0 : { from, to };
9760
+ }
9761
+ function readAmount(raw, name, errors) {
9762
+ if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
9763
+ const v = Number(raw);
9764
+ if (!Number.isFinite(v)) {
9765
+ errors.push(`${name} \uB97C \uC218\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(raw)}`);
9766
+ return void 0;
9767
+ }
9768
+ if (v < 0) {
9769
+ errors.push(`${name} \uAC00 \uC74C\uC218\uB2E4(${v})`);
9770
+ return void 0;
9771
+ }
9772
+ return v;
9773
+ }
9774
+ function ingestEnergyUsagePeriodRecords(records, opts) {
9775
+ const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
9776
+ const accepted = [];
9777
+ const rejected = [];
9778
+ let seq = 0;
9779
+ for (const r of list) {
9780
+ const errors = [];
9781
+ const meterId = String(r?.meterId ?? "").trim();
9782
+ if (!meterId) errors.push("meterId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC9C0\uC810\uC774 \uC4F4 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
9783
+ const span = readSpan(r, errors);
9784
+ const rawKWh = r?.kWh;
9785
+ let kWh;
9786
+ if (rawKWh === void 0 || rawKWh === null || String(rawKWh).trim() === "") {
9787
+ errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uADF8 \uAD6C\uAC04\uC5D0 \uC4F4 \uC591\uC774\uB2E4");
9788
+ } else {
9789
+ kWh = readAmount(rawKWh, "kWh", errors);
9790
+ }
9791
+ const maxKW = readAmount(r?.maxKW, "maxKW", errors);
9792
+ const unitPrice = readAmount(r?.unitPrice, "unitPrice", errors);
9793
+ const currency = String(r?.currency ?? "").trim() || void 0;
9794
+ if (unitPrice !== void 0 && !currency) errors.push("unitPrice \uB97C \uC2E4\uC5C8\uB294\uB370 currency \uAC00 \uC5C6\uB2E4 \u2014 \uB2E8\uC704 \uC5C6\uB294 \uAE08\uC561\uC740 \uB354\uD560 \uC218 \uC5C6\uB2E4");
9795
+ let basis;
9796
+ const rawBasis = r?.basis;
9797
+ if (rawBasis !== void 0 && rawBasis !== null && String(rawBasis).trim()) {
9798
+ const text = String(rawBasis).trim();
9799
+ if (!OBSERVATION_BASIS.includes(text)) {
9800
+ errors.push(`basis \uAC00 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${OBSERVATION_BASIS.join("\xB7")}): ${JSON.stringify(rawBasis)}`);
9801
+ } else basis = text;
9802
+ }
9803
+ if (errors.length || !span || kWh === void 0) {
9804
+ rejected.push({ record: r, errors });
9805
+ continue;
9806
+ }
9807
+ const data = {
9808
+ meterId,
9809
+ from: span.from,
9810
+ to: span.to,
9811
+ kWh,
9812
+ ...maxKW !== void 0 ? { maxKW } : {},
9813
+ ...unitPrice !== void 0 ? { unitPrice } : {},
9814
+ ...currency ? { currency } : {},
9815
+ ...basis ? { basis } : {}
9816
+ };
9817
+ accepted.push({
9818
+ eventId: `${opts.tenantId}-usage-period-${++seq}`,
9819
+ eventType: ENERGY_EVENT.usagePeriod,
9820
+ /* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
9821
+ eventTime: span.to,
9822
+ tenantId: opts.tenantId,
9823
+ data
9824
+ });
9825
+ }
9826
+ return { accepted, rejected };
9827
+ }
9828
+ function ingestEnergyBillRecords(records, opts) {
9829
+ const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
9830
+ const accepted = [];
9831
+ const rejected = [];
9832
+ let seq = 0;
9833
+ for (const r of list) {
9834
+ const errors = [];
9835
+ const span = readSpan(r, errors);
9836
+ const energyCharge = readAmount(r?.energyCharge, "energyCharge", errors);
9837
+ const demandCharge = readAmount(r?.demandCharge, "demandCharge", errors);
9838
+ const total = readAmount(r?.total, "total", errors);
9839
+ const billingDemandKW = readAmount(r?.billingDemandKW, "billingDemandKW", errors);
9840
+ if (energyCharge === void 0 && demandCharge === void 0 && total === void 0) {
9841
+ errors.push("\uAE08\uC561\uC774 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uC801\uC744 \uC0AC\uC2E4\uC774 \uC5C6\uB294 \uCCAD\uAD6C\uC11C\uB294 \uBC1B\uC9C0 \uC54A\uB294\uB2E4");
9842
+ }
9843
+ const currency = String(r?.currency ?? "").trim() || void 0;
9844
+ if (!currency) errors.push("currency \uAC00 \uC5C6\uB2E4 \u2014 \uB2E8\uC704 \uC5C6\uB294 \uAE08\uC561\uC740 \uB2E4\uB978 \uAE08\uC561\uACFC \uB354\uD560 \uC218 \uC5C6\uB2E4");
9845
+ if (errors.length || !span) {
9846
+ rejected.push({ record: r, errors });
9847
+ continue;
9848
+ }
9849
+ const data = {
9850
+ from: span.from,
9851
+ to: span.to,
9852
+ ...energyCharge !== void 0 ? { energyCharge } : {},
9853
+ ...demandCharge !== void 0 ? { demandCharge } : {},
9854
+ ...total !== void 0 ? { total } : {},
9855
+ ...currency ? { currency } : {},
9856
+ ...billingDemandKW !== void 0 ? { billingDemandKW } : {}
9857
+ };
9858
+ accepted.push({
9859
+ eventId: `${opts.tenantId}-bill-${++seq}`,
9860
+ eventType: ENERGY_EVENT.bill,
9861
+ eventTime: span.to,
9862
+ tenantId: opts.tenantId,
9863
+ data
9864
+ });
9865
+ }
9866
+ return { accepted, rejected };
9867
+ }
9637
9868
 
9638
9869
  // src/operational-ingest.ts
9639
9870
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
@@ -10142,20 +10373,33 @@ function electricityCost(input) {
10142
10373
  const t = input.tariff;
10143
10374
  const eRate = Number(t?.energyChargePerKWh);
10144
10375
  const dRate = Number(t?.demandChargePerKW);
10376
+ const billingKW = Number(t?.billingDemandKW);
10145
10377
  const hasE = Number.isFinite(eRate) && eRate > 0;
10146
10378
  const hasD = Number.isFinite(dRate) && dRate > 0;
10147
- if (!hasE && !hasD) return { reason: "no-tariff" };
10379
+ const hasBilling = Number.isFinite(billingKW) && billingKW > 0;
10380
+ const priced = Number(input.energyChargeFromPeriods);
10381
+ const hasPriced = Number.isFinite(priced) && priced >= 0;
10382
+ if (!hasE && !hasD && !hasPriced) return { reason: "no-tariff" };
10148
10383
  const kWh = Number(input.kWh);
10149
10384
  const peakKW = Number(input.peakKW);
10150
- const energyCharge = hasE && Number.isFinite(kWh) ? kWh * eRate : void 0;
10151
- const demandCharge = hasD && Number.isFinite(peakKW) ? peakKW * dRate : void 0;
10385
+ const energyCharge = hasPriced ? priced : hasE && Number.isFinite(kWh) ? kWh * eRate : void 0;
10386
+ const demandBase = hasBilling ? billingKW : peakKW;
10387
+ const demandCharge = hasD && Number.isFinite(demandBase) ? demandBase * dRate : void 0;
10152
10388
  const total = energyCharge === void 0 && demandCharge === void 0 ? void 0 : (energyCharge ?? 0) + (demandCharge ?? 0);
10389
+ const notModeled = NOT_MODELED.filter((k) => {
10390
+ if ((k === "tou" || k === "seasonal") && hasPriced) return false;
10391
+ if (k === "ratchet" && hasBilling) return false;
10392
+ return true;
10393
+ });
10153
10394
  return {
10154
10395
  ...energyCharge !== void 0 ? { energyCharge } : {},
10155
- ...demandCharge !== void 0 ? { demandCharge, demandBasis: "window-peak-as-period-peak" } : {},
10396
+ ...demandCharge !== void 0 ? {
10397
+ demandCharge,
10398
+ demandBasis: hasBilling ? "billing-demand" : "window-peak-as-period-peak"
10399
+ } : {},
10156
10400
  ...total !== void 0 ? { total } : {},
10157
10401
  ...t?.currency ? { currency: String(t.currency) } : {},
10158
- notModeled: NOT_MODELED
10402
+ notModeled
10159
10403
  };
10160
10404
  }
10161
10405
 
@@ -10240,6 +10484,7 @@ function retiredVocabularyIn(line) {
10240
10484
  MES_TYPES,
10241
10485
  METER_DIRECTION,
10242
10486
  MesKernel,
10487
+ OBSERVATION_BASIS,
10243
10488
  OPERATION_PROPERTY,
10244
10489
  OP_EVENT,
10245
10490
  OP_PARAM,
@@ -10308,16 +10553,20 @@ function retiredVocabularyIn(line) {
10308
10553
  inWorkCalendar,
10309
10554
  inWorkCalendarAt,
10310
10555
  ingest,
10556
+ ingestEnergyBillRecords,
10311
10557
  ingestEnergyEquipmentRecords,
10312
10558
  ingestEnergyGenerationRecords,
10313
10559
  ingestEnergyRecords,
10560
+ ingestEnergyUsagePeriodRecords,
10314
10561
  ingestMasterData,
10315
10562
  ingestOperationalRecords,
10316
10563
  isAggregationRecord,
10317
10564
  isElectricalLocationType,
10565
+ isEnergyBillRecord,
10318
10566
  isEnergyEquipmentRecord,
10319
10567
  isEnergyGenerationRecord,
10320
10568
  isEnergyRecord,
10569
+ isEnergyUsagePeriodRecord,
10321
10570
  isEpcisEventType,
10322
10571
  isEquipmentLevel,
10323
10572
  isMasterDataRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.71",
3
+ "version": "0.7.72",
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": {