@operato/twin-kernel 0.7.75 → 0.7.77

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.
@@ -2035,6 +2035,12 @@ export interface EnergyState {
2035
2035
  * 틀렸다는 뜻이고, 사람이 찾아내지 않아도 드러나야 한다.
2036
2036
  */
2037
2037
  lastBill?: EnergyBillData;
2038
+ /**
2039
+ * 마지막으로 받은 **요금 기준** — 이 주기에 적용되는 요금적용전력과 단가.
2040
+ *
2041
+ * 청구서보다 이쪽이 먼저다: 청구서는 끝난 기간의 정산이고 이것은 지금 적용되는 값이다.
2042
+ */
2043
+ tariffBasis?: EnergyTariffBasisData;
2038
2044
  /**
2039
2045
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
2040
2046
  *
@@ -2474,6 +2480,18 @@ export declare const ENERGY_EVENT: {
2474
2480
  * 쪽인지 말한다.
2475
2481
  */
2476
2482
  readonly usagePeriod: "energy.usage.period";
2483
+ /**
2484
+ * **이 주기에 적용되는 요금 기준이 정해졌다** — 요금적용전력과 기본요금 단가.
2485
+ *
2486
+ * 청구서와 다른 사건이다. 청구서는 끝난 기간의 정산이고 이것은 **지금 적용되는 기준**이다.
2487
+ * 그래서 사건 시각도 다르다 — 청구서는 기간의 끝, 이것은 기간의 시작이다(그때부터 유효하다).
2488
+ *
2489
+ * 이 값은 매 주기 다시 정해진다. 모델의 선언에 적어 두면 사람이 매달 갱신해야 하고, 안 하면 낡은
2490
+ * 값으로 기본요금이 조용히 틀린다. 공급자가 알려 주는 값을 그대로 받는 것이 맞다.
2491
+ *
2492
+ * **금액을 싣지 않는다.** 요금적용전력 × 단가는 커널이 계산한다. 곱을 받으면 그것이 정산인 척한다.
2493
+ */
2494
+ readonly tariffBasis: "energy.tariff.basis";
2477
2495
  /**
2478
2496
  * **청구서가 도착했다** — 공급자가 확정한 금액.
2479
2497
  *
@@ -2729,6 +2747,31 @@ export interface EnergyUsagePeriodData {
2729
2747
  * **커널의 요금 어휘로 받는다**(사용량 요금·기본요금·합계). 공급자마다 청구서의 칸 이름이 다르므로
2730
2748
  * 그 이름을 그대로 들이면 커널이 한 공급자의 모양에 묶인다. 옮기는 것은 커넥터의 일이다.
2731
2749
  */
2750
+ /**
2751
+ * 이 주기에 적용되는 요금 기준 — `ENERGY_EVENT.tariffBasis` 의 데이터.
2752
+ *
2753
+ * 사건 시각은 `from` 이다. 「이 시각부터 이 기준이 적용된다」이므로 주기가 끝나기 전에 성립하고,
2754
+ * `to` 로 시각을 잡으면 아직 오지 않은 시각이 저널에 적힌다.
2755
+ */
2756
+ export interface EnergyTariffBasisData {
2757
+ from: ISOTime;
2758
+ to: ISOTime;
2759
+ /** 기본요금이 걸리는 기준(kW) — 공급자가 정한다(§`TariffDeclaration.billingDemandKW`). */
2760
+ billingDemandKW?: number;
2761
+ /** 그 기준 1kW 당 기본요금. */
2762
+ demandChargePerKW?: number;
2763
+ currency?: string;
2764
+ }
2765
+ /**
2766
+ * 청구서 — 공급자가 **정산한** 금액.
2767
+ *
2768
+ * ── 지금 이 문으로 오는 것이 없다 (2026-08-29 확인) ────────────────────────
2769
+ * 붙어 있는 원본에 정산 금액을 주는 조회가 없다. 그 원본이 주는 것은 이 주기의 **요금 기준**이고
2770
+ * 그것은 다른 문으로 받는다(§`EnergyTariffBasisData`).
2771
+ *
2772
+ * 그 둘을 한 문으로 받으면 **우리가 곱한 값을 우리가 다시 빼는** 비교가 된다 — 실제로 한 번 그렇게
2773
+ * 했고, 차이가 0 인 것을 계산이 맞았다는 확인으로 읽었다. 같은 두 값을 두 번 곱한 것이었다.
2774
+ */
2732
2775
  export interface EnergyBillData {
2733
2776
  from: ISOTime;
2734
2777
  to: ISOTime;
package/dist/contract.js CHANGED
@@ -1046,6 +1046,18 @@ export const ENERGY_EVENT = {
1046
1046
  * 쪽인지 말한다.
1047
1047
  */
1048
1048
  usagePeriod: 'energy.usage.period',
1049
+ /**
1050
+ * **이 주기에 적용되는 요금 기준이 정해졌다** — 요금적용전력과 기본요금 단가.
1051
+ *
1052
+ * 청구서와 다른 사건이다. 청구서는 끝난 기간의 정산이고 이것은 **지금 적용되는 기준**이다.
1053
+ * 그래서 사건 시각도 다르다 — 청구서는 기간의 끝, 이것은 기간의 시작이다(그때부터 유효하다).
1054
+ *
1055
+ * 이 값은 매 주기 다시 정해진다. 모델의 선언에 적어 두면 사람이 매달 갱신해야 하고, 안 하면 낡은
1056
+ * 값으로 기본요금이 조용히 틀린다. 공급자가 알려 주는 값을 그대로 받는 것이 맞다.
1057
+ *
1058
+ * **금액을 싣지 않는다.** 요금적용전력 × 단가는 커널이 계산한다. 곱을 받으면 그것이 정산인 척한다.
1059
+ */
1060
+ tariffBasis: 'energy.tariff.basis',
1049
1061
  /**
1050
1062
  * **청구서가 도착했다** — 공급자가 확정한 금액.
1051
1063
  *
@@ -122,6 +122,13 @@ export declare class EmsKernel extends FlowEngine {
122
122
  * 모르는 지점이면 세어 둔다 — 조용히 버리면 「값이 왜 안 보이지」로만 남는다(계량과 같은 규율).
123
123
  */
124
124
  private applyUsagePeriod;
125
+ /**
126
+ * 이 주기에 적용되는 요금 기준을 상태에 둔다.
127
+ *
128
+ * 늦게 온 지난 주기의 기준이 최신을 덮지 않게 시작 시각으로 가린다. 같은 주기의 값이 다시 오면
129
+ * 새것을 쓴다 — 사업자가 그 주기의 기준을 고칠 수 있다.
130
+ */
131
+ private applyTariffBasis;
125
132
  /**
126
133
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
127
134
  *
@@ -197,8 +204,10 @@ export declare class EmsKernel extends FlowEngine {
197
204
  private generationPeriodObs;
198
205
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
199
206
  private unclosedGenerationPeriods;
200
- /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
207
+ /** 마지막으로 받은 청구서 — 공급자가 **정산한** 금액. 지금 이 문으로 오는 것은 없다. */
201
208
  private lastBill?;
209
+ /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
210
+ private tariffBasis?;
202
211
  /**
203
212
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
204
213
  *
@@ -328,6 +337,25 @@ export declare class EmsKernel extends FlowEngine {
328
337
  * 계기가 잰 방전으로 읽는다(수요 구간의 `derived` 와 같은 규율).
329
338
  */
330
339
  private emitEnergyEquipment;
340
+ /**
341
+ * 이 트윈의 **발전 정격** — 이용률의 분모가 될 값과 그 근거.
342
+ *
343
+ * ── 어느 정격으로 나누나 ───────────────────────────────────────────────────
344
+ * 교류가 먼저다. 계통으로 나가는 양은 교류 정격을 넘을 수 없으므로 그 비가 1 을 넘지 않는다.
345
+ * 직류로 나누면 잘 만든 발전소도 100% 에 닿지 않아 늘 낮게 보인다.
346
+ *
347
+ * **다만 시스템마다 관행이 다르다.** 직류로 나눠 보여 주는 화면이 실제로 있다. 그래서 커널은
348
+ * 어느 쪽으로 나눴는지를 값과 함께 낸다 — 두 화면의 수가 다를 때 사람이 맞춰 볼 수 있어야 한다.
349
+ *
350
+ * ── 어디서 읽나 ────────────────────────────────────────────────────────────
351
+ * 설비마다 선언돼 있으면 그 합이다. 없으면 자리(수전)에 선언된 합계를 쓴다. 둘 다 없으면
352
+ * 계산하지 않는다 — 0 이나 짐작을 내지 않는다.
353
+ */
354
+ generationRated(): {
355
+ kW: number;
356
+ basis: 'ac' | 'dc';
357
+ from: 'equipment' | 'location';
358
+ } | undefined;
331
359
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
332
360
  private numberProperty;
333
361
  /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
@@ -277,6 +277,10 @@ export class EmsKernel extends FlowEngine {
277
277
  this.applyBill(envelope);
278
278
  return;
279
279
  }
280
+ if (envelope.eventType === ENERGY_EVENT.tariffBasis) {
281
+ this.applyTariffBasis(envelope);
282
+ return;
283
+ }
280
284
  super.apply(envelope);
281
285
  }
282
286
  /**
@@ -337,6 +341,25 @@ export class EmsKernel extends FlowEngine {
337
341
  }
338
342
  this.revision++;
339
343
  }
344
+ /**
345
+ * 이 주기에 적용되는 요금 기준을 상태에 둔다.
346
+ *
347
+ * 늦게 온 지난 주기의 기준이 최신을 덮지 않게 시작 시각으로 가린다. 같은 주기의 값이 다시 오면
348
+ * 새것을 쓴다 — 사업자가 그 주기의 기준을 고칠 수 있다.
349
+ */
350
+ applyTariffBasis(envelope) {
351
+ const d = envelope.data;
352
+ if (!d?.from || !d?.to)
353
+ return;
354
+ const fromMs = Date.parse(String(d.from));
355
+ if (!Number.isFinite(fromMs))
356
+ return;
357
+ const priorFromMs = Date.parse(String(this.tariffBasis?.from ?? ''));
358
+ if (Number.isFinite(priorFromMs) && fromMs < priorFromMs)
359
+ return;
360
+ this.tariffBasis = { ...d };
361
+ this.revision++;
362
+ }
340
363
  /**
341
364
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
342
365
  *
@@ -600,8 +623,10 @@ export class EmsKernel extends FlowEngine {
600
623
  generationPeriodObs = new Map();
601
624
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
602
625
  unclosedGenerationPeriods = 0;
603
- /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
626
+ /** 마지막으로 받은 청구서 — 공급자가 **정산한** 금액. 지금 이 문으로 오는 것은 없다. */
604
627
  lastBill;
628
+ /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
629
+ tariffBasis;
605
630
  /**
606
631
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
607
632
  *
@@ -1051,7 +1076,11 @@ export class EmsKernel extends FlowEngine {
1051
1076
  billingDemandKW: Math.round(basis),
1052
1077
  overKW: Math.round(over),
1053
1078
  /* 값의 출처 — 청구서가 말한 기준인지 현장의 선언인지. 선언은 곧 낡는다. */
1054
- basisFrom: Number.isFinite(Number(this.lastBill?.billingDemandKW)) ? 'bill' : 'declaration',
1079
+ basisFrom: Number.isFinite(Number(this.tariffBasis?.billingDemandKW))
1080
+ ? 'tariff-basis'
1081
+ : Number.isFinite(Number(this.lastBill?.billingDemandKW))
1082
+ ? 'bill'
1083
+ : 'declaration',
1055
1084
  observedTo: high.atTo,
1056
1085
  periodSince: high.since
1057
1086
  },
@@ -1084,6 +1113,10 @@ export class EmsKernel extends FlowEngine {
1084
1113
  }
1085
1114
  /** 지금 쓰는 요금적용전력 — **청구서가 먼저다.** 매달 바뀌는 값이라 선언은 곧 낡는다. */
1086
1115
  billingDemandKW() {
1116
+ /* 지금 적용되는 기준이 먼저다 — 청구서는 끝난 기간의 것이라 이번 주기를 말하지 않는다. */
1117
+ const basis = Number(this.tariffBasis?.billingDemandKW);
1118
+ if (Number.isFinite(basis) && basis > 0)
1119
+ return basis;
1087
1120
  const billed = Number(this.lastBill?.billingDemandKW);
1088
1121
  if (Number.isFinite(billed) && billed > 0)
1089
1122
  return billed;
@@ -1377,6 +1410,49 @@ export class EmsKernel extends FlowEngine {
1377
1410
  emitEnergyEquipment(equipmentId, atMs, fields) {
1378
1411
  this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
1379
1412
  }
1413
+ /**
1414
+ * 이 트윈의 **발전 정격** — 이용률의 분모가 될 값과 그 근거.
1415
+ *
1416
+ * ── 어느 정격으로 나누나 ───────────────────────────────────────────────────
1417
+ * 교류가 먼저다. 계통으로 나가는 양은 교류 정격을 넘을 수 없으므로 그 비가 1 을 넘지 않는다.
1418
+ * 직류로 나누면 잘 만든 발전소도 100% 에 닿지 않아 늘 낮게 보인다.
1419
+ *
1420
+ * **다만 시스템마다 관행이 다르다.** 직류로 나눠 보여 주는 화면이 실제로 있다. 그래서 커널은
1421
+ * 어느 쪽으로 나눴는지를 값과 함께 낸다 — 두 화면의 수가 다를 때 사람이 맞춰 볼 수 있어야 한다.
1422
+ *
1423
+ * ── 어디서 읽나 ────────────────────────────────────────────────────────────
1424
+ * 설비마다 선언돼 있으면 그 합이다. 없으면 자리(수전)에 선언된 합계를 쓴다. 둘 다 없으면
1425
+ * 계산하지 않는다 — 0 이나 짐작을 내지 않는다.
1426
+ */
1427
+ generationRated() {
1428
+ for (const [prop, basis] of [
1429
+ [EMS_PROPERTY.genRatedKW, 'ac'],
1430
+ [EMS_PROPERTY.genRatedKWdc, 'dc']
1431
+ ]) {
1432
+ let sum = 0;
1433
+ for (const e of this.boardDef?.equipment ?? []) {
1434
+ const v = this.numberProperty(e, prop);
1435
+ if (v !== undefined && v > 0)
1436
+ sum += v;
1437
+ }
1438
+ if (sum > 0)
1439
+ return { kW: sum, basis, from: 'equipment' };
1440
+ }
1441
+ for (const [prop, basis] of [
1442
+ [EMS_PROPERTY.genRatedKW, 'ac'],
1443
+ [EMS_PROPERTY.genRatedKWdc, 'dc']
1444
+ ]) {
1445
+ let sum = 0;
1446
+ for (const loc of this.boardDef?.locations ?? []) {
1447
+ const v = this.numberProperty(loc, prop);
1448
+ if (v !== undefined && v > 0)
1449
+ sum += v;
1450
+ }
1451
+ if (sum > 0)
1452
+ return { kW: sum, basis, from: 'location' };
1453
+ }
1454
+ return undefined;
1455
+ }
1380
1456
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
1381
1457
  numberProperty(resource, id) {
1382
1458
  for (const p of resource?.properties ?? []) {
@@ -1482,6 +1558,8 @@ export class EmsKernel extends FlowEngine {
1482
1558
  /* 청구서도 이어받는다 — 재기동마다 사라지면 우리 계산과 견줄 기준이 없어진다. */
1483
1559
  if (e.lastBill?.from && e.lastBill?.to)
1484
1560
  this.lastBill = { ...e.lastBill };
1561
+ if (e.tariffBasis?.from && e.tariffBasis?.to)
1562
+ this.tariffBasis = { ...e.tariffBasis };
1485
1563
  /* 이 주기의 최고도 이어받는다 — 재기동마다 0 으로 돌아가면 이미 넘은 것을 못 넘었다고 말한다. */
1486
1564
  if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since)
1487
1565
  this.demandHigh = { ...e.demandHigh };
@@ -1510,6 +1588,7 @@ export class EmsKernel extends FlowEngine {
1510
1588
  : {}),
1511
1589
  ...(this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {}),
1512
1590
  ...(this.lastBill ? { lastBill: { ...this.lastBill } } : {}),
1591
+ ...(this.tariffBasis ? { tariffBasis: { ...this.tariffBasis } } : {}),
1513
1592
  ...(this.demandHigh ? { demandHigh: { ...this.demandHigh } } : {}),
1514
1593
  ...(contractKW !== undefined ? { contractKW } : {}),
1515
1594
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
@@ -70,8 +70,22 @@ export declare const EMS_PROPERTY: {
70
70
  * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
71
71
  */
72
72
  readonly initialSoc: "storage.initialSoc";
73
- /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
73
+ /**
74
+ * 발전 정격(kW) — **교류 쪽 최대 출력**. 계통으로 나가는 값이다.
75
+ *
76
+ * 이용률의 분모가 이 값이다(만든 양 ÷ 정격 × 시간). **직류 쪽 정격(§`genRatedKWdc`)과 다르고 더 작다** —
77
+ * 두 값을 한 이름에 담으면 이용률이 그 차이만큼 틀리고, 틀린 이유가 아무 데도 표시되지 않는다.
78
+ *
79
+ * 설비마다 선언하거나, 설비별 값을 모르는 현장은 **자리(수전)에 합계로** 선언한다.
80
+ */
74
81
  readonly genRatedKW: "generation.ratedKW";
82
+ /**
83
+ * 발전 정격(kW) — **직류 쪽**. 변환기(인버터)의 입력 쪽 정격이고, 태양광이면 패널 정격의 합이다.
84
+ *
85
+ * 이용률의 분모로 쓰지 않는다. 이 값이 교류 정격보다 큰 것은 설계이고(변환기를 그렇게 고른다),
86
+ * 그 차이가 「맑은 정오에도 교류 출력이 더 오르지 않는 이유」를 설명한다.
87
+ */
88
+ readonly genRatedKWdc: "generation.ratedKWdc";
75
89
  /**
76
90
  * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
77
91
  *
@@ -131,8 +131,22 @@ export const EMS_PROPERTY = {
131
131
  *
132
132
  * 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
133
133
  */
134
- /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
134
+ /**
135
+ * 발전 정격(kW) — **교류 쪽 최대 출력**. 계통으로 나가는 값이다.
136
+ *
137
+ * 이용률의 분모가 이 값이다(만든 양 ÷ 정격 × 시간). **직류 쪽 정격(§`genRatedKWdc`)과 다르고 더 작다** —
138
+ * 두 값을 한 이름에 담으면 이용률이 그 차이만큼 틀리고, 틀린 이유가 아무 데도 표시되지 않는다.
139
+ *
140
+ * 설비마다 선언하거나, 설비별 값을 모르는 현장은 **자리(수전)에 합계로** 선언한다.
141
+ */
135
142
  genRatedKW: 'generation.ratedKW',
143
+ /**
144
+ * 발전 정격(kW) — **직류 쪽**. 변환기(인버터)의 입력 쪽 정격이고, 태양광이면 패널 정격의 합이다.
145
+ *
146
+ * 이용률의 분모로 쓰지 않는다. 이 값이 교류 정격보다 큰 것은 설계이고(변환기를 그렇게 고른다),
147
+ * 그 차이가 「맑은 정오에도 교류 출력이 더 오르지 않는 이유」를 설명한다.
148
+ */
149
+ genRatedKWdc: 'generation.ratedKWdc',
136
150
  /**
137
151
  * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
138
152
  *
@@ -165,7 +179,16 @@ export const EMS_PROPERTY_SPEC = {
165
179
  /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
166
180
  [EMS_PROPERTY.reserveSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below.' },
167
181
  [EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' },
168
- [EMS_PROPERTY.genRatedKW]: { uom: 'kW', dataType: 'xs:double', note: 'rated generation output at clear-sky noon, in kW.' },
182
+ [EMS_PROPERTY.genRatedKW]: {
183
+ uom: 'kW',
184
+ dataType: 'xs:double',
185
+ note: 'rated AC generation output, in kW — the denominator of capacity factor. Declare per equipment, or on the incoming location as a site total.'
186
+ },
187
+ [EMS_PROPERTY.genRatedKWdc]: {
188
+ uom: 'kW',
189
+ dataType: 'xs:double',
190
+ note: 'rated DC generation capacity, in kW (panel rating for PV). Larger than the AC rating by design; not the capacity-factor denominator.'
191
+ },
169
192
  [EMS_PROPERTY.genDailyProfile]: {
170
193
  dataType: 'xs:string',
171
194
  note: 'daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time — the site declares its own curve; we do not invent one.'
@@ -115,6 +115,22 @@ export interface EnergyUsagePeriodRecord {
115
115
  basis?: string;
116
116
  }
117
117
  export declare function isEnergyUsagePeriodRecord(record: unknown): boolean;
118
+ /** 이 주기에 적용되는 요금 기준 — 공급자가 정하고 매 주기 다시 정해진다. */
119
+ export interface EnergyTariffBasisRecord {
120
+ from: string;
121
+ to: string;
122
+ billingDemandKW?: number;
123
+ demandChargePerKW?: number;
124
+ currency?: string;
125
+ }
126
+ export declare function isEnergyTariffBasisRecord(record: unknown): boolean;
127
+ /**
128
+ * 요금 기준을 봉투로 — **사건 시각은 `from`** 이다.
129
+ *
130
+ * 「이 시각부터 이 기준이 적용된다」이므로 주기가 끝나기 전에 성립한다. `to` 로 잡으면 아직 오지 않은
131
+ * 시각이 저널에 적힌다(실제로 그렇게 해서 9월 1일 사건이 8월 29일에 적힌 적이 있다).
132
+ */
133
+ export declare function ingestEnergyTariffBasisRecords(records: EnergyTariffBasisRecord | EnergyTariffBasisRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
118
134
  /** 청구서 — 커넥터가 커널의 요금 어휘로 옮겨 보낸다. */
119
135
  export interface EnergyBillRecord {
120
136
  from: string;
@@ -378,6 +378,73 @@ export function isEnergyUsagePeriodRecord(record) {
378
378
  const r = record;
379
379
  return !!r && typeof r === 'object' && r.meterId !== undefined && r.from !== undefined && r.to !== undefined && r.kWh !== undefined;
380
380
  }
381
+ export function isEnergyTariffBasisRecord(record) {
382
+ const r = record;
383
+ if (!r || typeof r !== 'object')
384
+ return false;
385
+ if (r.from === undefined || r.to === undefined)
386
+ return false;
387
+ /*
388
+ * ── 청구서와 배타로 (2026-08-29) ───────────────────────────────────────────
389
+ *
390
+ * 청구서가 요금적용전력을 함께 실으면 이 판별도 참이 됐다. 그때 이 문으로 가면 「금액은 받지
391
+ * 않는다」로 거부되어 **정산이 통째로 사라진다** — 거부는 되지만 그 사실을 잃는 것은 같다.
392
+ *
393
+ * 금액이 하나라도 있으면 그것은 정산이다. 기준은 금액을 싣지 않는다.
394
+ */
395
+ if (r.energyCharge !== undefined || r.demandCharge !== undefined || r.total !== undefined)
396
+ return false;
397
+ return r.billingDemandKW !== undefined || r.demandChargePerKW !== undefined;
398
+ }
399
+ /**
400
+ * 요금 기준을 봉투로 — **사건 시각은 `from`** 이다.
401
+ *
402
+ * 「이 시각부터 이 기준이 적용된다」이므로 주기가 끝나기 전에 성립한다. `to` 로 잡으면 아직 오지 않은
403
+ * 시각이 저널에 적힌다(실제로 그렇게 해서 9월 1일 사건이 8월 29일에 적힌 적이 있다).
404
+ */
405
+ export function ingestEnergyTariffBasisRecords(records, opts) {
406
+ const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
407
+ const accepted = [];
408
+ const rejected = [];
409
+ let seq = 0;
410
+ for (const r of list) {
411
+ const errors = [];
412
+ const span = readSpan(r, errors);
413
+ const billingDemandKW = readAmount(r?.billingDemandKW, 'billingDemandKW', errors);
414
+ const demandChargePerKW = readAmount(r?.demandChargePerKW, 'demandChargePerKW', errors);
415
+ if (billingDemandKW === undefined && demandChargePerKW === undefined) {
416
+ errors.push('요금 기준이 하나도 없다 — 적을 사실이 없는 기준은 받지 않는다');
417
+ }
418
+ /* 단가를 실었으면 통화가 있어야 한다 — 단위 없는 금액은 다른 금액과 더할 수 없다. */
419
+ const currency = String(r?.currency ?? '').trim() || undefined;
420
+ if (demandChargePerKW !== undefined && !currency)
421
+ errors.push('demandChargePerKW 를 실었는데 currency 가 없다');
422
+ /* 금액은 받지 않는다 — 두 값의 곱은 커널이 한다. 곱을 받으면 그것이 정산인 척한다. */
423
+ if (r?.demandCharge !== undefined || r?.total !== undefined) {
424
+ errors.push('금액(demandCharge·total)은 이 문이 받지 않는다 — 정산 금액은 청구서 문으로 보낸다');
425
+ }
426
+ if (errors.length || !span) {
427
+ rejected.push({ record: r, errors });
428
+ continue;
429
+ }
430
+ const data = {
431
+ from: span.from,
432
+ to: span.to,
433
+ ...(billingDemandKW !== undefined ? { billingDemandKW } : {}),
434
+ ...(demandChargePerKW !== undefined ? { demandChargePerKW } : {}),
435
+ ...(currency ? { currency } : {})
436
+ };
437
+ accepted.push({
438
+ eventId: `${opts.tenantId}-tariff-basis-${++seq}`,
439
+ eventType: ENERGY_EVENT.tariffBasis,
440
+ /* **시작 시각**이다 — 그때부터 유효하다(§ 위 주석). */
441
+ eventTime: span.from,
442
+ tenantId: opts.tenantId,
443
+ data
444
+ });
445
+ }
446
+ return { accepted, rejected };
447
+ }
381
448
  export function isEnergyBillRecord(record) {
382
449
  const r = record;
383
450
  if (!r || typeof r !== 'object')
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, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord } from './energy-ingest.ts';
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord } 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, EnergyUsagePeriodRecord, EnergyBillRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
38
+ export type { EnergyRecord, EnergyEquipmentRecord, EnergyGenerationRecord, EnergyUsagePeriodRecord, EnergyBillRecord, EnergyTariffBasisRecord, 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, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord } from "./energy-ingest.js";
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord } 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";
@@ -129,6 +129,7 @@ __export(index_exports, {
129
129
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
130
130
  ingestEnergyGenerationRecords: () => ingestEnergyGenerationRecords,
131
131
  ingestEnergyRecords: () => ingestEnergyRecords,
132
+ ingestEnergyTariffBasisRecords: () => ingestEnergyTariffBasisRecords,
132
133
  ingestEnergyUsagePeriodRecords: () => ingestEnergyUsagePeriodRecords,
133
134
  ingestMasterData: () => ingestMasterData,
134
135
  ingestOperationalRecords: () => ingestOperationalRecords,
@@ -138,6 +139,7 @@ __export(index_exports, {
138
139
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
139
140
  isEnergyGenerationRecord: () => isEnergyGenerationRecord,
140
141
  isEnergyRecord: () => isEnergyRecord,
142
+ isEnergyTariffBasisRecord: () => isEnergyTariffBasisRecord,
141
143
  isEnergyUsagePeriodRecord: () => isEnergyUsagePeriodRecord,
142
144
  isEpcisEventType: () => isEpcisEventType,
143
145
  isEquipmentLevel: () => isEquipmentLevel,
@@ -709,6 +711,18 @@ var ENERGY_EVENT = {
709
711
  * 쪽인지 말한다.
710
712
  */
711
713
  usagePeriod: "energy.usage.period",
714
+ /**
715
+ * **이 주기에 적용되는 요금 기준이 정해졌다** — 요금적용전력과 기본요금 단가.
716
+ *
717
+ * 청구서와 다른 사건이다. 청구서는 끝난 기간의 정산이고 이것은 **지금 적용되는 기준**이다.
718
+ * 그래서 사건 시각도 다르다 — 청구서는 기간의 끝, 이것은 기간의 시작이다(그때부터 유효하다).
719
+ *
720
+ * 이 값은 매 주기 다시 정해진다. 모델의 선언에 적어 두면 사람이 매달 갱신해야 하고, 안 하면 낡은
721
+ * 값으로 기본요금이 조용히 틀린다. 공급자가 알려 주는 값을 그대로 받는 것이 맞다.
722
+ *
723
+ * **금액을 싣지 않는다.** 요금적용전력 × 단가는 커널이 계산한다. 곱을 받으면 그것이 정산인 척한다.
724
+ */
725
+ tariffBasis: "energy.tariff.basis",
712
726
  /**
713
727
  * **청구서가 도착했다** — 공급자가 확정한 금액.
714
728
  *
@@ -7553,8 +7567,22 @@ var EMS_PROPERTY = {
7553
7567
  *
7554
7568
  * 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
7555
7569
  */
7556
- /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
7570
+ /**
7571
+ * 발전 정격(kW) — **교류 쪽 최대 출력**. 계통으로 나가는 값이다.
7572
+ *
7573
+ * 이용률의 분모가 이 값이다(만든 양 ÷ 정격 × 시간). **직류 쪽 정격(§`genRatedKWdc`)과 다르고 더 작다** —
7574
+ * 두 값을 한 이름에 담으면 이용률이 그 차이만큼 틀리고, 틀린 이유가 아무 데도 표시되지 않는다.
7575
+ *
7576
+ * 설비마다 선언하거나, 설비별 값을 모르는 현장은 **자리(수전)에 합계로** 선언한다.
7577
+ */
7557
7578
  genRatedKW: "generation.ratedKW",
7579
+ /**
7580
+ * 발전 정격(kW) — **직류 쪽**. 변환기(인버터)의 입력 쪽 정격이고, 태양광이면 패널 정격의 합이다.
7581
+ *
7582
+ * 이용률의 분모로 쓰지 않는다. 이 값이 교류 정격보다 큰 것은 설계이고(변환기를 그렇게 고른다),
7583
+ * 그 차이가 「맑은 정오에도 교류 출력이 더 오르지 않는 이유」를 설명한다.
7584
+ */
7585
+ genRatedKWdc: "generation.ratedKWdc",
7558
7586
  /**
7559
7587
  * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
7560
7588
  *
@@ -7585,7 +7613,16 @@ var EMS_PROPERTY_SPEC = {
7585
7613
  /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
7586
7614
  [EMS_PROPERTY.reserveSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below." },
7587
7615
  [EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." },
7588
- [EMS_PROPERTY.genRatedKW]: { uom: "kW", dataType: "xs:double", note: "rated generation output at clear-sky noon, in kW." },
7616
+ [EMS_PROPERTY.genRatedKW]: {
7617
+ uom: "kW",
7618
+ dataType: "xs:double",
7619
+ note: "rated AC generation output, in kW \u2014 the denominator of capacity factor. Declare per equipment, or on the incoming location as a site total."
7620
+ },
7621
+ [EMS_PROPERTY.genRatedKWdc]: {
7622
+ uom: "kW",
7623
+ dataType: "xs:double",
7624
+ note: "rated DC generation capacity, in kW (panel rating for PV). Larger than the AC rating by design; not the capacity-factor denominator."
7625
+ },
7589
7626
  [EMS_PROPERTY.genDailyProfile]: {
7590
7627
  dataType: "xs:string",
7591
7628
  note: "daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time \u2014 the site declares its own curve; we do not invent one."
@@ -7950,6 +7987,10 @@ var EmsKernel = class extends FlowEngine {
7950
7987
  this.applyBill(envelope);
7951
7988
  return;
7952
7989
  }
7990
+ if (envelope.eventType === ENERGY_EVENT.tariffBasis) {
7991
+ this.applyTariffBasis(envelope);
7992
+ return;
7993
+ }
7953
7994
  super.apply(envelope);
7954
7995
  }
7955
7996
  /**
@@ -7990,6 +8031,22 @@ var EmsKernel = class extends FlowEngine {
7990
8031
  }
7991
8032
  this.revision++;
7992
8033
  }
8034
+ /**
8035
+ * 이 주기에 적용되는 요금 기준을 상태에 둔다.
8036
+ *
8037
+ * 늦게 온 지난 주기의 기준이 최신을 덮지 않게 시작 시각으로 가린다. 같은 주기의 값이 다시 오면
8038
+ * 새것을 쓴다 — 사업자가 그 주기의 기준을 고칠 수 있다.
8039
+ */
8040
+ applyTariffBasis(envelope) {
8041
+ const d = envelope.data;
8042
+ if (!d?.from || !d?.to) return;
8043
+ const fromMs = Date.parse(String(d.from));
8044
+ if (!Number.isFinite(fromMs)) return;
8045
+ const priorFromMs = Date.parse(String(this.tariffBasis?.from ?? ""));
8046
+ if (Number.isFinite(priorFromMs) && fromMs < priorFromMs) return;
8047
+ this.tariffBasis = { ...d };
8048
+ this.revision++;
8049
+ }
7993
8050
  /**
7994
8051
  * 청구서를 상태에 둔다 — **우리 계산을 덮지 않는다.**
7995
8052
  *
@@ -8164,8 +8221,10 @@ var EmsKernel = class extends FlowEngine {
8164
8221
  generationPeriodObs = /* @__PURE__ */ new Map();
8165
8222
  /** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
8166
8223
  unclosedGenerationPeriods = 0;
8167
- /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
8224
+ /** 마지막으로 받은 청구서 — 공급자가 **정산한** 금액. 지금 이 문으로 오는 것은 없다. */
8168
8225
  lastBill;
8226
+ /** 마지막으로 받은 요금 기준 — 이 주기에 적용되는 요금적용전력과 단가. */
8227
+ tariffBasis;
8169
8228
  /**
8170
8229
  * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
8171
8230
  *
@@ -8484,7 +8543,7 @@ var EmsKernel = class extends FlowEngine {
8484
8543
  billingDemandKW: Math.round(basis),
8485
8544
  overKW: Math.round(over),
8486
8545
  /* 값의 출처 — 청구서가 말한 기준인지 현장의 선언인지. 선언은 곧 낡는다. */
8487
- basisFrom: Number.isFinite(Number(this.lastBill?.billingDemandKW)) ? "bill" : "declaration",
8546
+ basisFrom: Number.isFinite(Number(this.tariffBasis?.billingDemandKW)) ? "tariff-basis" : Number.isFinite(Number(this.lastBill?.billingDemandKW)) ? "bill" : "declaration",
8488
8547
  observedTo: high.atTo,
8489
8548
  periodSince: high.since
8490
8549
  },
@@ -8514,6 +8573,8 @@ var EmsKernel = class extends FlowEngine {
8514
8573
  }
8515
8574
  /** 지금 쓰는 요금적용전력 — **청구서가 먼저다.** 매달 바뀌는 값이라 선언은 곧 낡는다. */
8516
8575
  billingDemandKW() {
8576
+ const basis = Number(this.tariffBasis?.billingDemandKW);
8577
+ if (Number.isFinite(basis) && basis > 0) return basis;
8517
8578
  const billed = Number(this.lastBill?.billingDemandKW);
8518
8579
  if (Number.isFinite(billed) && billed > 0) return billed;
8519
8580
  return this.declaredBillingDemandKW();
@@ -8722,6 +8783,45 @@ var EmsKernel = class extends FlowEngine {
8722
8783
  emitEnergyEquipment(equipmentId, atMs, fields) {
8723
8784
  this.emitOp(ENERGY_EVENT.equipment, { equipmentId, at: new Date(atMs).toISOString(), derived: true, ...fields });
8724
8785
  }
8786
+ /**
8787
+ * 이 트윈의 **발전 정격** — 이용률의 분모가 될 값과 그 근거.
8788
+ *
8789
+ * ── 어느 정격으로 나누나 ───────────────────────────────────────────────────
8790
+ * 교류가 먼저다. 계통으로 나가는 양은 교류 정격을 넘을 수 없으므로 그 비가 1 을 넘지 않는다.
8791
+ * 직류로 나누면 잘 만든 발전소도 100% 에 닿지 않아 늘 낮게 보인다.
8792
+ *
8793
+ * **다만 시스템마다 관행이 다르다.** 직류로 나눠 보여 주는 화면이 실제로 있다. 그래서 커널은
8794
+ * 어느 쪽으로 나눴는지를 값과 함께 낸다 — 두 화면의 수가 다를 때 사람이 맞춰 볼 수 있어야 한다.
8795
+ *
8796
+ * ── 어디서 읽나 ────────────────────────────────────────────────────────────
8797
+ * 설비마다 선언돼 있으면 그 합이다. 없으면 자리(수전)에 선언된 합계를 쓴다. 둘 다 없으면
8798
+ * 계산하지 않는다 — 0 이나 짐작을 내지 않는다.
8799
+ */
8800
+ generationRated() {
8801
+ for (const [prop, basis] of [
8802
+ [EMS_PROPERTY.genRatedKW, "ac"],
8803
+ [EMS_PROPERTY.genRatedKWdc, "dc"]
8804
+ ]) {
8805
+ let sum = 0;
8806
+ for (const e of this.boardDef?.equipment ?? []) {
8807
+ const v = this.numberProperty(e, prop);
8808
+ if (v !== void 0 && v > 0) sum += v;
8809
+ }
8810
+ if (sum > 0) return { kW: sum, basis, from: "equipment" };
8811
+ }
8812
+ for (const [prop, basis] of [
8813
+ [EMS_PROPERTY.genRatedKW, "ac"],
8814
+ [EMS_PROPERTY.genRatedKWdc, "dc"]
8815
+ ]) {
8816
+ let sum = 0;
8817
+ for (const loc of this.boardDef?.locations ?? []) {
8818
+ const v = this.numberProperty(loc, prop);
8819
+ if (v !== void 0 && v > 0) sum += v;
8820
+ }
8821
+ if (sum > 0) return { kW: sum, basis, from: "location" };
8822
+ }
8823
+ return void 0;
8824
+ }
8725
8825
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
8726
8826
  numberProperty(resource, id) {
8727
8827
  for (const p of resource?.properties ?? []) {
@@ -8798,6 +8898,7 @@ var EmsKernel = class extends FlowEngine {
8798
8898
  }
8799
8899
  if (Number.isFinite(e.unclosedGenerationPeriods)) this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
8800
8900
  if (e.lastBill?.from && e.lastBill?.to) this.lastBill = { ...e.lastBill };
8901
+ if (e.tariffBasis?.from && e.tariffBasis?.to) this.tariffBasis = { ...e.tariffBasis };
8801
8902
  if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since) this.demandHigh = { ...e.demandHigh };
8802
8903
  }
8803
8904
  getSnapshot() {
@@ -8820,6 +8921,7 @@ var EmsKernel = class extends FlowEngine {
8820
8921
  } : {},
8821
8922
  ...this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {},
8822
8923
  ...this.lastBill ? { lastBill: { ...this.lastBill } } : {},
8924
+ ...this.tariffBasis ? { tariffBasis: { ...this.tariffBasis } } : {},
8823
8925
  ...this.demandHigh ? { demandHigh: { ...this.demandHigh } } : {},
8824
8926
  ...contractKW !== void 0 ? { contractKW } : {},
8825
8927
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
@@ -9866,6 +9968,53 @@ function isEnergyUsagePeriodRecord(record) {
9866
9968
  const r = record;
9867
9969
  return !!r && typeof r === "object" && r.meterId !== void 0 && r.from !== void 0 && r.to !== void 0 && r.kWh !== void 0;
9868
9970
  }
9971
+ function isEnergyTariffBasisRecord(record) {
9972
+ const r = record;
9973
+ if (!r || typeof r !== "object") return false;
9974
+ if (r.from === void 0 || r.to === void 0) return false;
9975
+ if (r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0) return false;
9976
+ return r.billingDemandKW !== void 0 || r.demandChargePerKW !== void 0;
9977
+ }
9978
+ function ingestEnergyTariffBasisRecords(records, opts) {
9979
+ const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
9980
+ const accepted = [];
9981
+ const rejected = [];
9982
+ let seq = 0;
9983
+ for (const r of list) {
9984
+ const errors = [];
9985
+ const span = readSpan(r, errors);
9986
+ const billingDemandKW = readAmount(r?.billingDemandKW, "billingDemandKW", errors);
9987
+ const demandChargePerKW = readAmount(r?.demandChargePerKW, "demandChargePerKW", errors);
9988
+ if (billingDemandKW === void 0 && demandChargePerKW === void 0) {
9989
+ errors.push("\uC694\uAE08 \uAE30\uC900\uC774 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uC801\uC744 \uC0AC\uC2E4\uC774 \uC5C6\uB294 \uAE30\uC900\uC740 \uBC1B\uC9C0 \uC54A\uB294\uB2E4");
9990
+ }
9991
+ const currency = String(r?.currency ?? "").trim() || void 0;
9992
+ if (demandChargePerKW !== void 0 && !currency) errors.push("demandChargePerKW \uB97C \uC2E4\uC5C8\uB294\uB370 currency \uAC00 \uC5C6\uB2E4");
9993
+ if (r?.demandCharge !== void 0 || r?.total !== void 0) {
9994
+ errors.push("\uAE08\uC561(demandCharge\xB7total)\uC740 \uC774 \uBB38\uC774 \uBC1B\uC9C0 \uC54A\uB294\uB2E4 \u2014 \uC815\uC0B0 \uAE08\uC561\uC740 \uCCAD\uAD6C\uC11C \uBB38\uC73C\uB85C \uBCF4\uB0B8\uB2E4");
9995
+ }
9996
+ if (errors.length || !span) {
9997
+ rejected.push({ record: r, errors });
9998
+ continue;
9999
+ }
10000
+ const data = {
10001
+ from: span.from,
10002
+ to: span.to,
10003
+ ...billingDemandKW !== void 0 ? { billingDemandKW } : {},
10004
+ ...demandChargePerKW !== void 0 ? { demandChargePerKW } : {},
10005
+ ...currency ? { currency } : {}
10006
+ };
10007
+ accepted.push({
10008
+ eventId: `${opts.tenantId}-tariff-basis-${++seq}`,
10009
+ eventType: ENERGY_EVENT.tariffBasis,
10010
+ /* **시작 시각**이다 — 그때부터 유효하다(§ 위 주석). */
10011
+ eventTime: span.from,
10012
+ tenantId: opts.tenantId,
10013
+ data
10014
+ });
10015
+ }
10016
+ return { accepted, rejected };
10017
+ }
9869
10018
  function isEnergyBillRecord(record) {
9870
10019
  const r = record;
9871
10020
  if (!r || typeof r !== "object") return false;
@@ -10683,6 +10832,7 @@ function retiredVocabularyIn(line) {
10683
10832
  ingestEnergyEquipmentRecords,
10684
10833
  ingestEnergyGenerationRecords,
10685
10834
  ingestEnergyRecords,
10835
+ ingestEnergyTariffBasisRecords,
10686
10836
  ingestEnergyUsagePeriodRecords,
10687
10837
  ingestMasterData,
10688
10838
  ingestOperationalRecords,
@@ -10692,6 +10842,7 @@ function retiredVocabularyIn(line) {
10692
10842
  isEnergyEquipmentRecord,
10693
10843
  isEnergyGenerationRecord,
10694
10844
  isEnergyRecord,
10845
+ isEnergyTariffBasisRecord,
10695
10846
  isEnergyUsagePeriodRecord,
10696
10847
  isEpcisEventType,
10697
10848
  isEquipmentLevel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.75",
3
+ "version": "0.7.77",
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": {