@operato/twin-kernel 0.7.71 → 0.7.73
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.
- package/dist/contract.d.ts +88 -0
- package/dist/contract.js +33 -0
- package/dist/ems-kernel.d.ts +19 -0
- package/dist/ems-kernel.js +71 -0
- package/dist/ems-profile.d.ts +11 -0
- package/dist/ems-profile.js +16 -0
- package/dist/energy-attribution.d.ts +38 -3
- package/dist/energy-attribution.js +28 -5
- package/dist/energy-ingest.d.ts +25 -0
- package/dist/energy-ingest.js +163 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist-cjs/index.cjs +256 -6
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -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;
|
package/dist/ems-kernel.js
CHANGED
|
@@ -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
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -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…). 없으면 금액에 단위를 붙이지 않는다. */
|
package/dist/ems-profile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
261
|
-
const
|
|
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
|
|
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
|
|
292
|
+
notModeled
|
|
270
293
|
};
|
|
271
294
|
}
|
package/dist/energy-ingest.d.ts
CHANGED
|
@@ -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;
|
package/dist/energy-ingest.js
CHANGED
|
@@ -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')
|
|
@@ -29,7 +29,19 @@ export function isEnergyRecord(record) {
|
|
|
29
29
|
const r = record;
|
|
30
30
|
/* 계량 지점이 있고 EPCIS 어휘가 없으면 에너지다. `epc` 가 함께 있으면 둘 중 무엇인지 알 수 없으므로
|
|
31
31
|
에너지로 받지 않는다 — 그 판단은 커넥터가 명확히 해야 한다. */
|
|
32
|
-
|
|
32
|
+
if (!(typeof r.meterId === 'string' && r.meterId.trim().length > 0 && r.epc === undefined))
|
|
33
|
+
return false;
|
|
34
|
+
/*
|
|
35
|
+
* ── **마감된 구간은 계량 표본이 아니다** (2026-08-29) ──────────────────────
|
|
36
|
+
*
|
|
37
|
+
* 둘 다 `meterId` 를 가지므로 이 판별이 겹쳤다. 겹친 채로 두면 갈림의 **순서**가 값을 정한다 —
|
|
38
|
+
* 순서를 잘못 두면 시간별 사용량이 계량 문으로 가고, 그 문은 `kWh` 를 적산 레지스터로 읽는다.
|
|
39
|
+
* 우리가 넣는 값은 이미 그 구간의 양이므로 **차분의 차분**이 된다.
|
|
40
|
+
*
|
|
41
|
+
* 가장 나쁜 모양이다: **거부되지 않고 값만 틀린다.** 그래서 순서에 기대지 않고 배타로 만든다
|
|
42
|
+
* (설비 상태와 발전 적산을 가를 때와 같은 규율).
|
|
43
|
+
*/
|
|
44
|
+
return r.from === undefined && r.to === undefined;
|
|
33
45
|
}
|
|
34
46
|
/**
|
|
35
47
|
* 표본 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 이유와 함께 남는다.
|
|
@@ -315,3 +327,152 @@ export function ingestEnergyGenerationRecords(records, opts) {
|
|
|
315
327
|
}
|
|
316
328
|
return { accepted, rejected };
|
|
317
329
|
}
|
|
330
|
+
export function isEnergyUsagePeriodRecord(record) {
|
|
331
|
+
const r = record;
|
|
332
|
+
return !!r && typeof r === 'object' && r.meterId !== undefined && r.from !== undefined && r.to !== undefined && r.kWh !== undefined;
|
|
333
|
+
}
|
|
334
|
+
export function isEnergyBillRecord(record) {
|
|
335
|
+
const r = record;
|
|
336
|
+
if (!r || typeof r !== 'object')
|
|
337
|
+
return false;
|
|
338
|
+
if (r.from === undefined || r.to === undefined)
|
|
339
|
+
return false;
|
|
340
|
+
return r.energyCharge !== undefined || r.demandCharge !== undefined || r.total !== undefined;
|
|
341
|
+
}
|
|
342
|
+
/** 두 시각을 읽고 순서를 본다 — 뒤집힌 구간은 받지 않는다(합이 음수가 된다). */
|
|
343
|
+
function readSpan(r, errors) {
|
|
344
|
+
const from = String(r?.from ?? '').trim();
|
|
345
|
+
const to = String(r?.to ?? '').trim();
|
|
346
|
+
const fromMs = Date.parse(from);
|
|
347
|
+
const toMs = Date.parse(to);
|
|
348
|
+
if (!from || !Number.isFinite(fromMs))
|
|
349
|
+
errors.push(`from 을 시각으로 읽을 수 없다: ${JSON.stringify(r?.from)}`);
|
|
350
|
+
if (!to || !Number.isFinite(toMs))
|
|
351
|
+
errors.push(`to 를 시각으로 읽을 수 없다: ${JSON.stringify(r?.to)}`);
|
|
352
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
|
|
353
|
+
errors.push(`구간이 뒤집혔거나 길이가 없다(${from} → ${to}) — 어느 쪽이 틀렸는지 우리가 정할 수 없다`);
|
|
354
|
+
}
|
|
355
|
+
return errors.length ? undefined : { from, to };
|
|
356
|
+
}
|
|
357
|
+
/** 0 이상의 수 하나 — 없으면 `undefined`, 값인데 읽을 수 없으면 오류. */
|
|
358
|
+
function readAmount(raw, name, errors) {
|
|
359
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
360
|
+
return undefined;
|
|
361
|
+
const v = Number(raw);
|
|
362
|
+
if (!Number.isFinite(v)) {
|
|
363
|
+
errors.push(`${name} 를 수로 읽을 수 없다: ${JSON.stringify(raw)}`);
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
if (v < 0) {
|
|
367
|
+
errors.push(`${name} 가 음수다(${v})`);
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
return v;
|
|
371
|
+
}
|
|
372
|
+
export function ingestEnergyUsagePeriodRecords(records, opts) {
|
|
373
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
374
|
+
const accepted = [];
|
|
375
|
+
const rejected = [];
|
|
376
|
+
let seq = 0;
|
|
377
|
+
for (const r of list) {
|
|
378
|
+
const errors = [];
|
|
379
|
+
const meterId = String(r?.meterId ?? '').trim();
|
|
380
|
+
if (!meterId)
|
|
381
|
+
errors.push('meterId 가 없다 — 어느 지점이 쓴 것인지 지어낼 수 없다');
|
|
382
|
+
const span = readSpan(r, errors);
|
|
383
|
+
const rawKWh = r?.kWh;
|
|
384
|
+
let kWh;
|
|
385
|
+
if (rawKWh === undefined || rawKWh === null || String(rawKWh).trim() === '') {
|
|
386
|
+
errors.push('kWh 가 없다 — 이 문이 받는 값은 그 구간에 쓴 양이다');
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
kWh = readAmount(rawKWh, 'kWh', errors);
|
|
390
|
+
}
|
|
391
|
+
const maxKW = readAmount(r?.maxKW, 'maxKW', errors);
|
|
392
|
+
const unitPrice = readAmount(r?.unitPrice, 'unitPrice', errors);
|
|
393
|
+
/* 단가를 실었으면 통화도 있어야 한다 — 단위 없는 금액은 다른 값과 더할 수 없다. */
|
|
394
|
+
const currency = String(r?.currency ?? '').trim() || undefined;
|
|
395
|
+
if (unitPrice !== undefined && !currency)
|
|
396
|
+
errors.push('unitPrice 를 실었는데 currency 가 없다 — 단위 없는 금액은 더할 수 없다');
|
|
397
|
+
/* 근거는 아는 목록 안에서만 — 모르는 값을 받으면 화면이 그것을 근거로 말한다. */
|
|
398
|
+
let basis;
|
|
399
|
+
const rawBasis = r?.basis;
|
|
400
|
+
if (rawBasis !== undefined && rawBasis !== null && String(rawBasis).trim()) {
|
|
401
|
+
const text = String(rawBasis).trim();
|
|
402
|
+
if (!OBSERVATION_BASIS.includes(text)) {
|
|
403
|
+
errors.push(`basis 가 아는 값이 아니다(${OBSERVATION_BASIS.join('·')}): ${JSON.stringify(rawBasis)}`);
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
basis = text;
|
|
407
|
+
}
|
|
408
|
+
if (errors.length || !span || kWh === undefined) {
|
|
409
|
+
rejected.push({ record: r, errors });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const data = {
|
|
413
|
+
meterId,
|
|
414
|
+
from: span.from,
|
|
415
|
+
to: span.to,
|
|
416
|
+
kWh,
|
|
417
|
+
...(maxKW !== undefined ? { maxKW } : {}),
|
|
418
|
+
...(unitPrice !== undefined ? { unitPrice } : {}),
|
|
419
|
+
...(currency ? { currency } : {}),
|
|
420
|
+
...(basis ? { basis } : {})
|
|
421
|
+
};
|
|
422
|
+
accepted.push({
|
|
423
|
+
eventId: `${opts.tenantId}-usage-period-${++seq}`,
|
|
424
|
+
eventType: ENERGY_EVENT.usagePeriod,
|
|
425
|
+
/* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
|
|
426
|
+
eventTime: span.to,
|
|
427
|
+
tenantId: opts.tenantId,
|
|
428
|
+
data
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return { accepted, rejected };
|
|
432
|
+
}
|
|
433
|
+
export function ingestEnergyBillRecords(records, opts) {
|
|
434
|
+
const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
435
|
+
const accepted = [];
|
|
436
|
+
const rejected = [];
|
|
437
|
+
let seq = 0;
|
|
438
|
+
for (const r of list) {
|
|
439
|
+
const errors = [];
|
|
440
|
+
const span = readSpan(r, errors);
|
|
441
|
+
const energyCharge = readAmount(r?.energyCharge, 'energyCharge', errors);
|
|
442
|
+
const demandCharge = readAmount(r?.demandCharge, 'demandCharge', errors);
|
|
443
|
+
const total = readAmount(r?.total, 'total', errors);
|
|
444
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, 'billingDemandKW', errors);
|
|
445
|
+
if (energyCharge === undefined && demandCharge === undefined && total === undefined) {
|
|
446
|
+
errors.push('금액이 하나도 없다 — 적을 사실이 없는 청구서는 받지 않는다');
|
|
447
|
+
}
|
|
448
|
+
/* 금액을 실었으면 통화가 있어야 한다. 없으면 그 수는 다른 수와 더할 수 없다. */
|
|
449
|
+
const currency = String(r?.currency ?? '').trim() || undefined;
|
|
450
|
+
if (!currency)
|
|
451
|
+
errors.push('currency 가 없다 — 단위 없는 금액은 다른 금액과 더할 수 없다');
|
|
452
|
+
/*
|
|
453
|
+
* 합계가 부분들과 다른 것은 **거부하지 않는다.** 세금·역률 요금·최소 청구액처럼 우리가 세지 않는
|
|
454
|
+
* 항목이 청구서에 있을 수 있다. 그 차이는 사실이고, 읽는 쪽이 그것을 보고 판단한다.
|
|
455
|
+
*/
|
|
456
|
+
if (errors.length || !span) {
|
|
457
|
+
rejected.push({ record: r, errors });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const data = {
|
|
461
|
+
from: span.from,
|
|
462
|
+
to: span.to,
|
|
463
|
+
...(energyCharge !== undefined ? { energyCharge } : {}),
|
|
464
|
+
...(demandCharge !== undefined ? { demandCharge } : {}),
|
|
465
|
+
...(total !== undefined ? { total } : {}),
|
|
466
|
+
...(currency ? { currency } : {}),
|
|
467
|
+
...(billingDemandKW !== undefined ? { billingDemandKW } : {})
|
|
468
|
+
};
|
|
469
|
+
accepted.push({
|
|
470
|
+
eventId: `${opts.tenantId}-bill-${++seq}`,
|
|
471
|
+
eventType: ENERGY_EVENT.bill,
|
|
472
|
+
eventTime: span.to,
|
|
473
|
+
tenantId: opts.tenantId,
|
|
474
|
+
data
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return { accepted, rejected };
|
|
478
|
+
}
|
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";
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -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 })) } : {}
|
|
@@ -9429,7 +9531,8 @@ var TwinRuntime = class {
|
|
|
9429
9531
|
function isEnergyRecord(record) {
|
|
9430
9532
|
if (!record || typeof record !== "object") return false;
|
|
9431
9533
|
const r = record;
|
|
9432
|
-
|
|
9534
|
+
if (!(typeof r.meterId === "string" && r.meterId.trim().length > 0 && r.epc === void 0)) return false;
|
|
9535
|
+
return r.from === void 0 && r.to === void 0;
|
|
9433
9536
|
}
|
|
9434
9537
|
function ingestEnergyRecords(records, opts) {
|
|
9435
9538
|
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
@@ -9634,6 +9737,135 @@ function ingestEnergyGenerationRecords(records, opts) {
|
|
|
9634
9737
|
}
|
|
9635
9738
|
return { accepted, rejected };
|
|
9636
9739
|
}
|
|
9740
|
+
function isEnergyUsagePeriodRecord(record) {
|
|
9741
|
+
const r = record;
|
|
9742
|
+
return !!r && typeof r === "object" && r.meterId !== void 0 && r.from !== void 0 && r.to !== void 0 && r.kWh !== void 0;
|
|
9743
|
+
}
|
|
9744
|
+
function isEnergyBillRecord(record) {
|
|
9745
|
+
const r = record;
|
|
9746
|
+
if (!r || typeof r !== "object") return false;
|
|
9747
|
+
if (r.from === void 0 || r.to === void 0) return false;
|
|
9748
|
+
return r.energyCharge !== void 0 || r.demandCharge !== void 0 || r.total !== void 0;
|
|
9749
|
+
}
|
|
9750
|
+
function readSpan(r, errors) {
|
|
9751
|
+
const from = String(r?.from ?? "").trim();
|
|
9752
|
+
const to = String(r?.to ?? "").trim();
|
|
9753
|
+
const fromMs = Date.parse(from);
|
|
9754
|
+
const toMs = Date.parse(to);
|
|
9755
|
+
if (!from || !Number.isFinite(fromMs)) errors.push(`from \uC744 \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.from)}`);
|
|
9756
|
+
if (!to || !Number.isFinite(toMs)) errors.push(`to \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(r?.to)}`);
|
|
9757
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMs) && !(toMs > fromMs)) {
|
|
9758
|
+
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`);
|
|
9759
|
+
}
|
|
9760
|
+
return errors.length ? void 0 : { from, to };
|
|
9761
|
+
}
|
|
9762
|
+
function readAmount(raw, name, errors) {
|
|
9763
|
+
if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
|
|
9764
|
+
const v = Number(raw);
|
|
9765
|
+
if (!Number.isFinite(v)) {
|
|
9766
|
+
errors.push(`${name} \uB97C \uC218\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(raw)}`);
|
|
9767
|
+
return void 0;
|
|
9768
|
+
}
|
|
9769
|
+
if (v < 0) {
|
|
9770
|
+
errors.push(`${name} \uAC00 \uC74C\uC218\uB2E4(${v})`);
|
|
9771
|
+
return void 0;
|
|
9772
|
+
}
|
|
9773
|
+
return v;
|
|
9774
|
+
}
|
|
9775
|
+
function ingestEnergyUsagePeriodRecords(records, opts) {
|
|
9776
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
9777
|
+
const accepted = [];
|
|
9778
|
+
const rejected = [];
|
|
9779
|
+
let seq = 0;
|
|
9780
|
+
for (const r of list) {
|
|
9781
|
+
const errors = [];
|
|
9782
|
+
const meterId = String(r?.meterId ?? "").trim();
|
|
9783
|
+
if (!meterId) errors.push("meterId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC9C0\uC810\uC774 \uC4F4 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
9784
|
+
const span = readSpan(r, errors);
|
|
9785
|
+
const rawKWh = r?.kWh;
|
|
9786
|
+
let kWh;
|
|
9787
|
+
if (rawKWh === void 0 || rawKWh === null || String(rawKWh).trim() === "") {
|
|
9788
|
+
errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uADF8 \uAD6C\uAC04\uC5D0 \uC4F4 \uC591\uC774\uB2E4");
|
|
9789
|
+
} else {
|
|
9790
|
+
kWh = readAmount(rawKWh, "kWh", errors);
|
|
9791
|
+
}
|
|
9792
|
+
const maxKW = readAmount(r?.maxKW, "maxKW", errors);
|
|
9793
|
+
const unitPrice = readAmount(r?.unitPrice, "unitPrice", errors);
|
|
9794
|
+
const currency = String(r?.currency ?? "").trim() || void 0;
|
|
9795
|
+
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");
|
|
9796
|
+
let basis;
|
|
9797
|
+
const rawBasis = r?.basis;
|
|
9798
|
+
if (rawBasis !== void 0 && rawBasis !== null && String(rawBasis).trim()) {
|
|
9799
|
+
const text = String(rawBasis).trim();
|
|
9800
|
+
if (!OBSERVATION_BASIS.includes(text)) {
|
|
9801
|
+
errors.push(`basis \uAC00 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${OBSERVATION_BASIS.join("\xB7")}): ${JSON.stringify(rawBasis)}`);
|
|
9802
|
+
} else basis = text;
|
|
9803
|
+
}
|
|
9804
|
+
if (errors.length || !span || kWh === void 0) {
|
|
9805
|
+
rejected.push({ record: r, errors });
|
|
9806
|
+
continue;
|
|
9807
|
+
}
|
|
9808
|
+
const data = {
|
|
9809
|
+
meterId,
|
|
9810
|
+
from: span.from,
|
|
9811
|
+
to: span.to,
|
|
9812
|
+
kWh,
|
|
9813
|
+
...maxKW !== void 0 ? { maxKW } : {},
|
|
9814
|
+
...unitPrice !== void 0 ? { unitPrice } : {},
|
|
9815
|
+
...currency ? { currency } : {},
|
|
9816
|
+
...basis ? { basis } : {}
|
|
9817
|
+
};
|
|
9818
|
+
accepted.push({
|
|
9819
|
+
eventId: `${opts.tenantId}-usage-period-${++seq}`,
|
|
9820
|
+
eventType: ENERGY_EVENT.usagePeriod,
|
|
9821
|
+
/* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
|
|
9822
|
+
eventTime: span.to,
|
|
9823
|
+
tenantId: opts.tenantId,
|
|
9824
|
+
data
|
|
9825
|
+
});
|
|
9826
|
+
}
|
|
9827
|
+
return { accepted, rejected };
|
|
9828
|
+
}
|
|
9829
|
+
function ingestEnergyBillRecords(records, opts) {
|
|
9830
|
+
const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
|
|
9831
|
+
const accepted = [];
|
|
9832
|
+
const rejected = [];
|
|
9833
|
+
let seq = 0;
|
|
9834
|
+
for (const r of list) {
|
|
9835
|
+
const errors = [];
|
|
9836
|
+
const span = readSpan(r, errors);
|
|
9837
|
+
const energyCharge = readAmount(r?.energyCharge, "energyCharge", errors);
|
|
9838
|
+
const demandCharge = readAmount(r?.demandCharge, "demandCharge", errors);
|
|
9839
|
+
const total = readAmount(r?.total, "total", errors);
|
|
9840
|
+
const billingDemandKW = readAmount(r?.billingDemandKW, "billingDemandKW", errors);
|
|
9841
|
+
if (energyCharge === void 0 && demandCharge === void 0 && total === void 0) {
|
|
9842
|
+
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");
|
|
9843
|
+
}
|
|
9844
|
+
const currency = String(r?.currency ?? "").trim() || void 0;
|
|
9845
|
+
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");
|
|
9846
|
+
if (errors.length || !span) {
|
|
9847
|
+
rejected.push({ record: r, errors });
|
|
9848
|
+
continue;
|
|
9849
|
+
}
|
|
9850
|
+
const data = {
|
|
9851
|
+
from: span.from,
|
|
9852
|
+
to: span.to,
|
|
9853
|
+
...energyCharge !== void 0 ? { energyCharge } : {},
|
|
9854
|
+
...demandCharge !== void 0 ? { demandCharge } : {},
|
|
9855
|
+
...total !== void 0 ? { total } : {},
|
|
9856
|
+
...currency ? { currency } : {},
|
|
9857
|
+
...billingDemandKW !== void 0 ? { billingDemandKW } : {}
|
|
9858
|
+
};
|
|
9859
|
+
accepted.push({
|
|
9860
|
+
eventId: `${opts.tenantId}-bill-${++seq}`,
|
|
9861
|
+
eventType: ENERGY_EVENT.bill,
|
|
9862
|
+
eventTime: span.to,
|
|
9863
|
+
tenantId: opts.tenantId,
|
|
9864
|
+
data
|
|
9865
|
+
});
|
|
9866
|
+
}
|
|
9867
|
+
return { accepted, rejected };
|
|
9868
|
+
}
|
|
9637
9869
|
|
|
9638
9870
|
// src/operational-ingest.ts
|
|
9639
9871
|
var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
|
|
@@ -10142,20 +10374,33 @@ function electricityCost(input) {
|
|
|
10142
10374
|
const t = input.tariff;
|
|
10143
10375
|
const eRate = Number(t?.energyChargePerKWh);
|
|
10144
10376
|
const dRate = Number(t?.demandChargePerKW);
|
|
10377
|
+
const billingKW = Number(t?.billingDemandKW);
|
|
10145
10378
|
const hasE = Number.isFinite(eRate) && eRate > 0;
|
|
10146
10379
|
const hasD = Number.isFinite(dRate) && dRate > 0;
|
|
10147
|
-
|
|
10380
|
+
const hasBilling = Number.isFinite(billingKW) && billingKW > 0;
|
|
10381
|
+
const priced = Number(input.energyChargeFromPeriods);
|
|
10382
|
+
const hasPriced = Number.isFinite(priced) && priced >= 0;
|
|
10383
|
+
if (!hasE && !hasD && !hasPriced) return { reason: "no-tariff" };
|
|
10148
10384
|
const kWh = Number(input.kWh);
|
|
10149
10385
|
const peakKW = Number(input.peakKW);
|
|
10150
|
-
const energyCharge = hasE && Number.isFinite(kWh) ? kWh * eRate : void 0;
|
|
10151
|
-
const
|
|
10386
|
+
const energyCharge = hasPriced ? priced : hasE && Number.isFinite(kWh) ? kWh * eRate : void 0;
|
|
10387
|
+
const demandBase = hasBilling ? billingKW : peakKW;
|
|
10388
|
+
const demandCharge = hasD && Number.isFinite(demandBase) ? demandBase * dRate : void 0;
|
|
10152
10389
|
const total = energyCharge === void 0 && demandCharge === void 0 ? void 0 : (energyCharge ?? 0) + (demandCharge ?? 0);
|
|
10390
|
+
const notModeled = NOT_MODELED.filter((k) => {
|
|
10391
|
+
if ((k === "tou" || k === "seasonal") && hasPriced) return false;
|
|
10392
|
+
if (k === "ratchet" && hasBilling) return false;
|
|
10393
|
+
return true;
|
|
10394
|
+
});
|
|
10153
10395
|
return {
|
|
10154
10396
|
...energyCharge !== void 0 ? { energyCharge } : {},
|
|
10155
|
-
...demandCharge !== void 0 ? {
|
|
10397
|
+
...demandCharge !== void 0 ? {
|
|
10398
|
+
demandCharge,
|
|
10399
|
+
demandBasis: hasBilling ? "billing-demand" : "window-peak-as-period-peak"
|
|
10400
|
+
} : {},
|
|
10156
10401
|
...total !== void 0 ? { total } : {},
|
|
10157
10402
|
...t?.currency ? { currency: String(t.currency) } : {},
|
|
10158
|
-
notModeled
|
|
10403
|
+
notModeled
|
|
10159
10404
|
};
|
|
10160
10405
|
}
|
|
10161
10406
|
|
|
@@ -10240,6 +10485,7 @@ function retiredVocabularyIn(line) {
|
|
|
10240
10485
|
MES_TYPES,
|
|
10241
10486
|
METER_DIRECTION,
|
|
10242
10487
|
MesKernel,
|
|
10488
|
+
OBSERVATION_BASIS,
|
|
10243
10489
|
OPERATION_PROPERTY,
|
|
10244
10490
|
OP_EVENT,
|
|
10245
10491
|
OP_PARAM,
|
|
@@ -10308,16 +10554,20 @@ function retiredVocabularyIn(line) {
|
|
|
10308
10554
|
inWorkCalendar,
|
|
10309
10555
|
inWorkCalendarAt,
|
|
10310
10556
|
ingest,
|
|
10557
|
+
ingestEnergyBillRecords,
|
|
10311
10558
|
ingestEnergyEquipmentRecords,
|
|
10312
10559
|
ingestEnergyGenerationRecords,
|
|
10313
10560
|
ingestEnergyRecords,
|
|
10561
|
+
ingestEnergyUsagePeriodRecords,
|
|
10314
10562
|
ingestMasterData,
|
|
10315
10563
|
ingestOperationalRecords,
|
|
10316
10564
|
isAggregationRecord,
|
|
10317
10565
|
isElectricalLocationType,
|
|
10566
|
+
isEnergyBillRecord,
|
|
10318
10567
|
isEnergyEquipmentRecord,
|
|
10319
10568
|
isEnergyGenerationRecord,
|
|
10320
10569
|
isEnergyRecord,
|
|
10570
|
+
isEnergyUsagePeriodRecord,
|
|
10321
10571
|
isEpcisEventType,
|
|
10322
10572
|
isEquipmentLevel,
|
|
10323
10573
|
isMasterDataRecord,
|
package/package.json
CHANGED