@operato/twin-kernel 0.7.69 → 0.7.70
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/capability.d.ts +9 -0
- package/dist/capability.js +1 -1
- package/dist/contract.d.ts +141 -0
- package/dist/contract.js +24 -0
- package/dist/ems-kernel.d.ts +37 -0
- package/dist/ems-kernel.js +177 -3
- package/dist/energy-ingest.d.ts +10 -0
- package/dist/energy-ingest.js +51 -2
- package/dist-cjs/index.cjs +162 -5
- package/package.json +1 -1
package/dist/capability.d.ts
CHANGED
|
@@ -57,6 +57,15 @@ export interface GeneratingState {
|
|
|
57
57
|
generatedKWhSince?: string;
|
|
58
58
|
/** 되돌아간 판정이 사실인가 짐작인가 — 'declared' 또는 'inferred'. */
|
|
59
59
|
generatedKWhBasis?: 'declared' | 'inferred';
|
|
60
|
+
/** 어떤 누적인가 — 원본이 말하지 않으면 'unknown' 이다(칸이 비지 않는다). */
|
|
61
|
+
generatedKWhAccumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
|
|
62
|
+
/** 끝난 기간의 발전량(kWh) — 성과지표가 기간당 에너지로 정의된다. */
|
|
63
|
+
generatedKWhLastPeriod?: number;
|
|
64
|
+
/** 그 기간이 끝난 시각. */
|
|
65
|
+
generatedKWhLastPeriodEnd?: string;
|
|
66
|
+
/** 그 기간에서 처음·마지막으로 관측한 시각 — 기간 경계와 다르면 그만큼 재지 않았다. */
|
|
67
|
+
generatedKWhLastPeriodObservedFrom?: string;
|
|
68
|
+
generatedKWhLastPeriodObservedTo?: string;
|
|
60
69
|
}
|
|
61
70
|
/** 저장 — 담고 낸다. 충전과 방전을 나눈다(손실·수명 판단이 둘을 구별해야 한다). */
|
|
62
71
|
export interface StoringState {
|
package/dist/capability.js
CHANGED
|
@@ -86,7 +86,7 @@ export const CAPABILITIES = {
|
|
|
86
86
|
* 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
|
|
87
87
|
* 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
|
|
88
88
|
*/
|
|
89
|
-
stateFields: ['generatedKW', 'exportKW', 'generatedKWh', 'generatedKWhResetAt', 'generatedKWhSince', 'generatedKWhBasis'], models: ['GeneratingState'], results: ['generated']
|
|
89
|
+
stateFields: ['generatedKW', 'exportKW', 'generatedKWh', 'generatedKWhResetAt', 'generatedKWhSince', 'generatedKWhBasis', 'generatedKWhAccumulation', 'generatedKWhLastPeriod', 'generatedKWhLastPeriodEnd', 'generatedKWhLastPeriodObservedFrom', 'generatedKWhLastPeriodObservedTo'], models: ['GeneratingState'], results: ['generated']
|
|
90
90
|
},
|
|
91
91
|
/*
|
|
92
92
|
* ── 왜 `storing` 이 아니라 `energyStoring` 인가 (2026-08-15) ────────────────
|
package/dist/contract.d.ts
CHANGED
|
@@ -930,6 +930,16 @@ export interface WorkCalendarEntry {
|
|
|
930
930
|
}
|
|
931
931
|
/** 선언된 기준의 요일(0 일요일 … 6 토요일). */
|
|
932
932
|
export declare function weekdayAt(ms: number, utcOffsetMinutes?: number): number;
|
|
933
|
+
/**
|
|
934
|
+
* 선언된 기준으로 **며칠째인가** — 하루 단위 셈의 경계를 정하는 데 쓴다(기간 발전량 등).
|
|
935
|
+
*
|
|
936
|
+
* 요일·분과 같은 규칙이다: 선언된 오프셋을 더한 뒤 UTC 로 읽는다. 선언이 없으면 UTC 의 하루다.
|
|
937
|
+
* 고정 오프셋이므로 일광절약시간을 쓰는 현장에서는 계절에 따라 한 시간 어긋난다 — 그 현장이 생기면
|
|
938
|
+
* 오프셋이 아니라 지역 이름(`TwinSpace.timezone`)을 커널까지 내려야 한다.
|
|
939
|
+
*/
|
|
940
|
+
export declare function localDayIndexAt(ms: number, utcOffsetMinutes?: number): number;
|
|
941
|
+
/** 그 날의 시작(UTC ms) — `localDayIndexAt` 의 역함수. */
|
|
942
|
+
export declare function localDayStartMs(dayIndex: number, utcOffsetMinutes?: number): number;
|
|
933
943
|
export declare function inWorkCalendar(entries: readonly WorkCalendarEntry[] | undefined, minuteOfDay: number): boolean;
|
|
934
944
|
/**
|
|
935
945
|
* 이 **시각**이 근무 시간인가 — 되풀이(`HH:MM`)와 **한 번뿐인 구간**(휴일·정비창)을 함께 본다.
|
|
@@ -1395,6 +1405,45 @@ export interface EquipmentState extends EffectivePeriod {
|
|
|
1395
1405
|
* 자정이 계기 교체로 보인다. 짐작을 사실처럼 적지 않기 위한 칸이다.
|
|
1396
1406
|
*/
|
|
1397
1407
|
generatedKWhBasis?: 'declared' | 'inferred';
|
|
1408
|
+
/**
|
|
1409
|
+
* 지금 든 적산이 **어떤 누적인가** — 원본이 말하지 않으면 `'unknown'` 이다(칸이 비지 않는다).
|
|
1410
|
+
*
|
|
1411
|
+
* 「모른다」를 값으로 두는 이유: 이 칸이 비어 있으면 소비처가 그 수를 평생치로 읽는다. 그러면 하루치를
|
|
1412
|
+
* 주는 발전소의 「지금까지 낸 양」이 오늘치가 되고, 아무도 그것을 알아채지 못한다.
|
|
1413
|
+
*/
|
|
1414
|
+
generatedKWhAccumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
|
|
1415
|
+
/**
|
|
1416
|
+
* **끝난 기간의 발전량**(kWh) — 가장 최근에 마감된 한 기간의 총량.
|
|
1417
|
+
*
|
|
1418
|
+
* ── 왜 이 칸이 필요한가 (2026-08-28) ──────────────────────────────────────
|
|
1419
|
+
* ISO 50001 의 성과지표(EnPI)와 기준선은 「기간당 에너지」로 정의된다. 태양광 성능비도 분자가 기간
|
|
1420
|
+
* 발전량이다. 적산값 하나만 두면 그 어느 것도 계산할 수 없다.
|
|
1421
|
+
*
|
|
1422
|
+
* 기간의 경계는 **둘 중 하나에서** 온다. 어느 쪽이었는지 사건에 적는다(§`EnergyGenerationPeriodData`).
|
|
1423
|
+
*
|
|
1424
|
+
* 원본의 선언 `generatedKWhSince` 가 나아갔다 — 그 시각이 곧 앞 기간의 끝이다
|
|
1425
|
+
* 현장의 선언 `TwinModelDef.utcOffsetMinutes` 로 정한 그 지역의 자정
|
|
1426
|
+
*
|
|
1427
|
+
* 커널이 시간대를 짐작하는 일은 없다. 선언이 없으면 UTC 로 읽고, 그것이 선언되지 않았다는 사실은
|
|
1428
|
+
* 트윈 정의에 남는다(§`utcOffsetMinutes`).
|
|
1429
|
+
*
|
|
1430
|
+
* 총량은 계산이 아니라 관측이다 — 하루치를 주는 원본은 마지막으로 본 값 그대로이고, 평생 적산을 주는
|
|
1431
|
+
* 원본은 그날 처음 본 값과의 차이다. 관측이 미치지 못한 구간은 사건이 시각으로 밝힌다.
|
|
1432
|
+
*
|
|
1433
|
+
* 같은 값이 사건으로도 나간다(`ENERGY_EVENT.generationPeriod`). 상태에만 두면 지난 기간들을 합할 수
|
|
1434
|
+
* 없다.
|
|
1435
|
+
*/
|
|
1436
|
+
generatedKWhLastPeriod?: number;
|
|
1437
|
+
/** 그 기간이 끝난 시각. 원본이 새 기준점을 말했으면 그 시각이고, 아니면 되돌아감을 본 시각이다. */
|
|
1438
|
+
generatedKWhLastPeriodEnd?: ISOTime;
|
|
1439
|
+
/**
|
|
1440
|
+
* 그 기간에서 **처음·마지막으로 관측한 시각** — 기간의 경계와 다르면 그만큼 재지 않았다.
|
|
1441
|
+
*
|
|
1442
|
+
* 이 둘이 없으면 상태만 읽는 화면이 덜 잰 값을 온전한 값으로 읽는다. 얼마나 어긋나면 문제인지는
|
|
1443
|
+
* 현장이 정할 일이므로 커널이 판정하지 않고 시각을 그대로 낸다(판정을 두 벌 만들지 않는다).
|
|
1444
|
+
*/
|
|
1445
|
+
generatedKWhLastPeriodObservedFrom?: ISOTime;
|
|
1446
|
+
generatedKWhLastPeriodObservedTo?: ISOTime;
|
|
1398
1447
|
/** storing — 충전율(%)·충전·방전(kW). 충전과 방전을 나눈다(손실·수명 판단이 그 둘을 구별한다). */
|
|
1399
1448
|
soc?: number;
|
|
1400
1449
|
chargeKW?: number;
|
|
@@ -1960,6 +2009,33 @@ export interface EnergyState {
|
|
|
1960
2009
|
hook: string;
|
|
1961
2010
|
count: number;
|
|
1962
2011
|
}[];
|
|
2012
|
+
/**
|
|
2013
|
+
* 설비마다 **지금 기간의 발전 관측** — 기간 발전량을 확정할 때 쓰는 기준점.
|
|
2014
|
+
*
|
|
2015
|
+
* 계량 지점이 `kWhAtWindowStart` 를 상태에 두는 것과 같은 이유다: 재기동에서 이것이 없으면 그날의
|
|
2016
|
+
* 발전량이 부팅 이후로만 잡히고, **적게 잡힌 것이 드러나지 않는다.**
|
|
2017
|
+
*/
|
|
2018
|
+
generationPeriods?: GenerationPeriodObservation[];
|
|
2019
|
+
/**
|
|
2020
|
+
* **마감하지 못한 기간 누적**의 수 — 월·요금기간 종류인데 원본이 기준점(`kWhSince`)을 말하지 않으면
|
|
2021
|
+
* 커널은 그 기간이 언제 끝나는지 알 수 없다(자정으로는 끝나지 않는다).
|
|
2022
|
+
*
|
|
2023
|
+
* 세어 두는 이유: 세지 않으면 그 발전소의 발전량이 아무 표시 없이 영원히 나오지 않는다. 0 이면 이
|
|
2024
|
+
* 칸을 만들지 않는다.
|
|
2025
|
+
*/
|
|
2026
|
+
unclosedGenerationPeriods?: number;
|
|
2027
|
+
}
|
|
2028
|
+
/** 지금 기간의 발전 관측 — 기간 발전량은 이 기준점과 마지막 관측의 관계로 정해진다. */
|
|
2029
|
+
export interface GenerationPeriodObservation {
|
|
2030
|
+
equipmentId: string;
|
|
2031
|
+
/** 선언된 시각 기준의 며칠째인가(§`localDayIndexAt`). */
|
|
2032
|
+
dayIndex: number;
|
|
2033
|
+
/** 이 기간에서 처음 본 적산과 그 시각 — 평생 적산의 기준점이다. */
|
|
2034
|
+
fromKWh: number;
|
|
2035
|
+
fromMs: number;
|
|
2036
|
+
/** 이 기간에서 마지막으로 본 적산과 그 시각 — 하루 누적은 이것이 곧 총량이다. */
|
|
2037
|
+
lastKWh: number;
|
|
2038
|
+
lastMs: number;
|
|
1963
2039
|
}
|
|
1964
2040
|
/**
|
|
1965
2041
|
* 조치방향 — code=안정 조치 키(언어 중립). command 있으면 원클릭 실행, 없으면 권고.
|
|
@@ -2360,6 +2436,16 @@ export declare const ENERGY_EVENT: {
|
|
|
2360
2436
|
readonly tariffShift: "energy.tariff.shift";
|
|
2361
2437
|
/** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
|
|
2362
2438
|
readonly generated: "energy.generated";
|
|
2439
|
+
/**
|
|
2440
|
+
* **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
|
|
2441
|
+
*
|
|
2442
|
+
* ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
|
|
2443
|
+
* 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
|
|
2444
|
+
*
|
|
2445
|
+
* 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
|
|
2446
|
+
* 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
|
|
2447
|
+
*/
|
|
2448
|
+
readonly generationPeriod: "energy.generation.period";
|
|
2363
2449
|
/** 저장(ESS 충전). */
|
|
2364
2450
|
readonly stored: "energy.stored";
|
|
2365
2451
|
/** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
|
|
@@ -2445,6 +2531,40 @@ export interface EnergyGeneratedData {
|
|
|
2445
2531
|
* 짐작이라는 사실을 상태에 남긴다(§`generatedKWhBasis`).
|
|
2446
2532
|
*/
|
|
2447
2533
|
since?: ISOTime;
|
|
2534
|
+
/**
|
|
2535
|
+
* **어떤 누적인가** — 표준이 값의 형에 두는 축이다 (2026-08-28).
|
|
2536
|
+
*
|
|
2537
|
+
* ── 왜 구간의 시작만으로 부족한가 ───────────────────────────────────────────
|
|
2538
|
+
* 처음에는 `since`(구간의 시작) 하나만 두었다. 그러면 **원본이 그것을 모를 때 아무 표도 남지 않는다** —
|
|
2539
|
+
* 소비처는 `574.8` 만 보고 그것이 하루치인지 평생치인지 알 방법이 없다. 되돌아가야 비로소 「모른다」가
|
|
2540
|
+
* 남는데(§`generatedKWhBasis`), 그전까지는 그 값을 다른 값과 비교해도 아무도 막지 않는다.
|
|
2541
|
+
*
|
|
2542
|
+
* 이 트윈의 규율은 **모르는 것을 모른다고 말하는 것**이다. 그래서 모름도 값으로 둔다.
|
|
2543
|
+
*
|
|
2544
|
+
* ── 이 축의 근거 ────────────────────────────────────────────────────────────
|
|
2545
|
+
* **표준이 강제해서 두는 축이 아니다.** 근거는 위 문단 하나로 끝난다 — 기준점을 모르는 값을 받으면
|
|
2546
|
+
* 커널이 추론하고, 추론한 것이 사실로 적힌다. 종류와 구간은 겹치지 않는 물음에 답한다: 종류는
|
|
2547
|
+
* 「견줄 수 있나」, 시각은 「어느 구간인가」.
|
|
2548
|
+
*
|
|
2549
|
+
* 곁근거로 IEC 61968-9 의 `ReadingType` 이 계측값의 성질을 여러 축으로 나눈다고 알고 있다(누적 방식·
|
|
2550
|
+
* 거시 구간·방향). **다만 그 문서를 읽지 않았다** — 유료 표준이고 이 저장소에 사본이 없다. 축 이름은
|
|
2551
|
+
* 기억이므로 이 주석을 근거로 인용하지 말 것. 커널이 **채택 선언한** 에너지 표준은 IEC 61850(설비·
|
|
2552
|
+
* 계측 모델)과 ISO 50001(경영)이다(§`StandardClass`).
|
|
2553
|
+
*
|
|
2554
|
+
* 어느 표준도 「적산은 평생치다」라고 말하지 않는다. 그 전제는 커널의 판정에 있었다 —
|
|
2555
|
+
* 값이 줄면 계기의 이상으로 읽는 것(§`generatedKWhResetAt`). 그것이 매일 자정을 계기 교체로
|
|
2556
|
+
* 적게 만든 자리다.
|
|
2557
|
+
*
|
|
2558
|
+
* 'lifetime' 설치 이후 — 단조 증가한다. 줄면 계기의 이상이다
|
|
2559
|
+
* 'daily' 그날부터
|
|
2560
|
+
* 'monthly' 그달부터
|
|
2561
|
+
* 'billing' 요금기간부터 — 경계는 계약이 정하므로 시각을 함께 받아야 구간이 정해진다
|
|
2562
|
+
* 'unknown' **원본이 말하지 않는다.** 이 값을 다른 값과 비교하지 말라는 뜻이다
|
|
2563
|
+
*
|
|
2564
|
+
* 없으면(칸이 아예 없으면) 커널은 `'unknown'` 으로 둔다 — 예전 판이 보내던 레코드가 조용히 평생치로
|
|
2565
|
+
* 읽히지 않게.
|
|
2566
|
+
*/
|
|
2567
|
+
accumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
|
|
2448
2568
|
at: ISOTime;
|
|
2449
2569
|
}
|
|
2450
2570
|
/** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
|
|
@@ -2468,6 +2588,27 @@ export interface EnergyMeasuredData {
|
|
|
2468
2588
|
at: ISOTime;
|
|
2469
2589
|
}
|
|
2470
2590
|
/** 수요 구간 마감 — 계약 대비 판정의 단위. */
|
|
2591
|
+
/**
|
|
2592
|
+
* 기간 발전량 확정 — `ENERGY_EVENT.generationPeriod` 의 데이터.
|
|
2593
|
+
*
|
|
2594
|
+
* 관측 구간을 함께 싣는 이유: 기간의 끝이 자정인데 마지막 표본이 저녁 여덟 시였다면, 그 사이는 재지
|
|
2595
|
+
* 않은 것이다. 태양광이면 대개 0 이지만 그것은 현장이 판단할 일이고, 재지 않은 것을 0 으로 적으면
|
|
2596
|
+
* 우리가 그 판단을 대신한 것이 된다.
|
|
2597
|
+
*/
|
|
2598
|
+
export interface EnergyGenerationPeriodData {
|
|
2599
|
+
equipmentId: string;
|
|
2600
|
+
/** 이 기간에 만든 양(kWh). */
|
|
2601
|
+
kWh: number;
|
|
2602
|
+
periodStart: ISOTime;
|
|
2603
|
+
periodEnd: ISOTime;
|
|
2604
|
+
/** 이 기간에서 처음·마지막으로 관측한 시각. 기간 경계와 다르면 그만큼 재지 않았다. */
|
|
2605
|
+
observedFrom: ISOTime;
|
|
2606
|
+
observedTo: ISOTime;
|
|
2607
|
+
/** 원본이 준 적산의 종류 — 총량을 어떻게 얻었는지가 이것으로 갈린다. */
|
|
2608
|
+
accumulation: 'lifetime' | 'daily' | 'monthly' | 'billing';
|
|
2609
|
+
/** 기간의 경계가 어디서 왔나 — 원본의 기준점(`declared`)이거나 현장의 시각 기준(`offset`). */
|
|
2610
|
+
boundary: 'declared' | 'offset';
|
|
2611
|
+
}
|
|
2471
2612
|
export interface EnergyDemandWindowData {
|
|
2472
2613
|
/** 배전 구간(로케이션 id) 또는 현장 전체. */
|
|
2473
2614
|
feederId?: string;
|
package/dist/contract.js
CHANGED
|
@@ -642,6 +642,20 @@ function onDeclaredDay(e, minuteOfDay, weekday) {
|
|
|
642
642
|
export function weekdayAt(ms, utcOffsetMinutes) {
|
|
643
643
|
return new Date(ms + (utcOffsetMinutes ?? 0) * 60_000).getUTCDay();
|
|
644
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* 선언된 기준으로 **며칠째인가** — 하루 단위 셈의 경계를 정하는 데 쓴다(기간 발전량 등).
|
|
647
|
+
*
|
|
648
|
+
* 요일·분과 같은 규칙이다: 선언된 오프셋을 더한 뒤 UTC 로 읽는다. 선언이 없으면 UTC 의 하루다.
|
|
649
|
+
* 고정 오프셋이므로 일광절약시간을 쓰는 현장에서는 계절에 따라 한 시간 어긋난다 — 그 현장이 생기면
|
|
650
|
+
* 오프셋이 아니라 지역 이름(`TwinSpace.timezone`)을 커널까지 내려야 한다.
|
|
651
|
+
*/
|
|
652
|
+
export function localDayIndexAt(ms, utcOffsetMinutes) {
|
|
653
|
+
return Math.floor((ms + (utcOffsetMinutes ?? 0) * 60_000) / 86_400_000);
|
|
654
|
+
}
|
|
655
|
+
/** 그 날의 시작(UTC ms) — `localDayIndexAt` 의 역함수. */
|
|
656
|
+
export function localDayStartMs(dayIndex, utcOffsetMinutes) {
|
|
657
|
+
return dayIndex * 86_400_000 - (utcOffsetMinutes ?? 0) * 60_000;
|
|
658
|
+
}
|
|
645
659
|
/**
|
|
646
660
|
* 절대 구간이 그 시각을 덮는가 — 한쪽만 있으면 그쪽만 본다(열린 구간).
|
|
647
661
|
* 깨진 시각으로는 판정하지 않는다(짐작해 고치지 않는다).
|
|
@@ -1031,6 +1045,16 @@ export const ENERGY_EVENT = {
|
|
|
1031
1045
|
tariffShift: 'energy.tariff.shift',
|
|
1032
1046
|
/** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
|
|
1033
1047
|
generated: 'energy.generated',
|
|
1048
|
+
/**
|
|
1049
|
+
* **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
|
|
1050
|
+
*
|
|
1051
|
+
* ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
|
|
1052
|
+
* 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
|
|
1053
|
+
*
|
|
1054
|
+
* 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
|
|
1055
|
+
* 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
|
|
1056
|
+
*/
|
|
1057
|
+
generationPeriod: 'energy.generation.period',
|
|
1034
1058
|
/** 저장(ESS 충전). */
|
|
1035
1059
|
stored: 'energy.stored',
|
|
1036
1060
|
/** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -141,8 +141,45 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
141
141
|
* 않으면 늦게 온 옛 적산이 최신 값을 뒤로 돌린다 — 그리고 그것이 「계기가 되돌아갔다」로 잘못 적힌다.
|
|
142
142
|
*/
|
|
143
143
|
private applyGenerated;
|
|
144
|
+
/**
|
|
145
|
+
* **기간이 끝났으면 그 기간의 발전량을 사실로 낸다.**
|
|
146
|
+
*
|
|
147
|
+
* ── 없던 것 (2026-08-28) ────────────────────────────────────────────────────
|
|
148
|
+
* 적산값을 상태에 세우고 끝이었다. 그래서 「어제 얼마 냈나」·「이번 달 성능비」에 답할 자리가 없었고,
|
|
149
|
+
* 매일 자정에 값이 되돌아가는 원본에서는 그 하루의 총량이 **버려졌다** — 우리가 마지막으로 본 값이
|
|
150
|
+
* 곧 그날의 총량인데도.
|
|
151
|
+
*
|
|
152
|
+
* ISO 50001 의 성과지표와 기준선은 기간당 에너지로 정의된다(§`domain-catalog` 에너지 표준). 적산
|
|
153
|
+
* 하나로는 계산할 수 없다.
|
|
154
|
+
*
|
|
155
|
+
* ── 경계는 둘 중 하나에서 온다 ──────────────────────────────────────────────
|
|
156
|
+
* 원본의 기준점이 나아갔다 그 시각이 앞 기간의 끝이다 → boundary 'declared'
|
|
157
|
+
* 현장의 자정을 지났다 `utcOffsetMinutes` 가 정한 자정 → boundary 'offset'
|
|
158
|
+
*
|
|
159
|
+
* ── 총량을 얻는 방법이 종류마다 다르다 ──────────────────────────────────────
|
|
160
|
+
* 'daily' 마지막으로 본 값이 그대로 그날의 총량이다
|
|
161
|
+
* 'lifetime' 그날 처음 본 값과의 차이
|
|
162
|
+
* 'monthly'·'billing' 원본이 기준점을 말했을 때만 마감한다 — 자정으로는 그 기간이 끝나지 않는다
|
|
163
|
+
* 'unknown' 마감하지 않는다. 어느 기간의 값인지 모르는 수를 기간 사실로 낼 수 없다
|
|
164
|
+
*
|
|
165
|
+
* 관측 구간(`observedFrom`·`observedTo`)을 함께 낸다. 기간의 끝은 자정인데 마지막 표본이 저녁이었다면
|
|
166
|
+
* 그 사이는 재지 않은 것이고, 재지 않은 것을 0 으로 적으면 우리가 현장의 판단을 대신하는 것이 된다.
|
|
167
|
+
*/
|
|
168
|
+
private closeGenerationPeriod;
|
|
169
|
+
private localDayIndex;
|
|
170
|
+
private dayStartMs;
|
|
144
171
|
/** 설비마다 발전 적산을 마지막으로 들은 시각 — 늦게 온 옛 표본을 가리는 데만 쓴다. */
|
|
145
172
|
private generationHeardAtMs;
|
|
173
|
+
/**
|
|
174
|
+
* 설비마다 **지금 기간의 관측** — 기간 발전량을 확정할 때 쓰는 기준점(§`closeGenerationPeriod`).
|
|
175
|
+
*
|
|
176
|
+
* **스냅샷에 낸다**(`EnergyState.generationPeriods`). 처음에는 「셈을 위한 값이니 상태에 두지 않는다」고
|
|
177
|
+
* 두었는데 그것이 틀렸다 — 재기동하면 평생 적산의 기준점이 사라지고, 그날의 발전량이 부팅 이후로만
|
|
178
|
+
* 잡힌다. 계량 지점이 `kWhAtWindowStart` 를 상태에 두는 것과 같은 이유다.
|
|
179
|
+
*/
|
|
180
|
+
private generationPeriodObs;
|
|
181
|
+
/** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
|
|
182
|
+
private unclosedGenerationPeriods;
|
|
146
183
|
private applyEquipmentEnergy;
|
|
147
184
|
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
148
185
|
private unknownEquipmentReports;
|
package/dist/ems-kernel.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { FlowEngine } from "./flow-engine.js";
|
|
29
29
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
30
|
-
import { CMD, ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
|
|
30
|
+
import { CMD, ENERGY_EVENT, minuteOfDayAt, localDayIndexAt, localDayStartMs } from "./contract.js";
|
|
31
31
|
import { EMS_PROPERTY, METER_DIRECTION, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
|
|
32
32
|
/** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
|
|
33
33
|
export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
|
|
@@ -341,21 +341,169 @@ export class EmsKernel extends FlowEngine {
|
|
|
341
341
|
const priorSince = eq.generatedKWhSince;
|
|
342
342
|
const since = typeof d?.since === 'string' && d.since.trim() ? d.since.trim() : undefined;
|
|
343
343
|
const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
|
|
344
|
-
|
|
344
|
+
/*
|
|
345
|
+
* ── 「사실」이라 말할 수 있는 조건은 **둘을 다 아는 것**이다 (2026-08-28, 고침) ─
|
|
346
|
+
*
|
|
347
|
+
* 앞 판은 `since ? 'declared' : 'inferred'` 였다. 그래서 **기준점을 뒤늦게 알게 되는 원본**에서
|
|
348
|
+
* 첫 되돌아감이 「짐작인데 사실로」 기록됐다 — 인티그레이션 레인이 짚었다.
|
|
349
|
+
*
|
|
350
|
+
* 표본 1..N 기준점 없음
|
|
351
|
+
* 표본 N+1 기준점이 처음 왔고, 값이 줄었다
|
|
352
|
+
* → `priorSince` 가 없으니 구간이 나아갔는지 알 수 없다
|
|
353
|
+
* → 그런데 `since` 가 있으므로 'declared' 가 붙었다
|
|
354
|
+
*
|
|
355
|
+
* 갈래를 정확히 적는다. **같은 기준점에서 줄었을 때만** 계기의 이상이라고 말할 수 있다.
|
|
356
|
+
*
|
|
357
|
+
* 기준점 둘 다 있고 나아갔다 구간이 새로 시작했다 → 되돌아감으로 적지 않는다
|
|
358
|
+
* 기준점 둘 다 있고 같다 계기의 이상이다 → 'declared'
|
|
359
|
+
* 기준점이 뒤로 갔다 모른다 → 'inferred' (원본이 옛 기준을 다시 말했다)
|
|
360
|
+
* 기준점이 이제 처음 왔다 모른다 → 'inferred'
|
|
361
|
+
* 기준점이 없다 모른다 → 'inferred'
|
|
362
|
+
*/
|
|
363
|
+
const sameSince = !!since && !!priorSince && Date.parse(since) === Date.parse(priorSince);
|
|
364
|
+
/*
|
|
365
|
+
* ── 종류를 알면 기준점 없이도 경계를 안다 (2026-08-28) ─────────────────────
|
|
366
|
+
*
|
|
367
|
+
* 앞 판은 `since` 만 보았다. 그래서 하루 누적을 선언한 원본이 기준점을 주지 않으면 매일 자정이
|
|
368
|
+
* 계기 교체로 기록됐다 — 종류를 말해 주었는데도.
|
|
369
|
+
*
|
|
370
|
+
* 그리고 **같은 날 안에서** 줄어든 것은 종류가 무엇이든 이상이다: 하루 누적의 경계는 자정이고,
|
|
371
|
+
* 자정이 어디인지는 현장의 선언(`utcOffsetMinutes`)이 답한다.
|
|
372
|
+
*/
|
|
373
|
+
const accumulation = typeof d?.accumulation === 'string' ? d.accumulation : undefined;
|
|
374
|
+
const kind = (accumulation ?? eq.generatedKWhAccumulation);
|
|
375
|
+
const periodic = kind === 'daily' || kind === 'monthly' || kind === 'billing';
|
|
376
|
+
const dropped = Number.isFinite(before) && kWh < before;
|
|
377
|
+
const obs = this.generationPeriodObs.get(id);
|
|
378
|
+
const dayIndex = this.localDayIndex(atMs);
|
|
379
|
+
const sameDayDrop = dropped && obs !== undefined && obs.dayIndex === dayIndex;
|
|
380
|
+
if (dropped && !periodAdvanced && (!periodic || sameDayDrop)) {
|
|
345
381
|
/* 되돌아갔다 — 값은 새것으로 두고, 그 사실을 시각으로 남긴다(우리가 계단을 메우지 않는다). */
|
|
346
382
|
;
|
|
347
383
|
eq.generatedKWhResetAt = new Date(atMs).toISOString();
|
|
348
|
-
eq.generatedKWhBasis =
|
|
384
|
+
eq.generatedKWhBasis = sameSince ? 'declared' : 'inferred';
|
|
349
385
|
}
|
|
350
386
|
/* 기준점은 온 것만 세운다 — 오지 않았다고 앞서 받은 것을 지우지 않는다(결측 ≠ 없음). */
|
|
351
387
|
if (since)
|
|
352
388
|
eq.generatedKWhSince = since;
|
|
389
|
+
/*
|
|
390
|
+
* ── 누적의 종류 — **모름도 값이다** (2026-08-28) ────────────────────────────
|
|
391
|
+
*
|
|
392
|
+
* 이 칸을 비워 두면 소비처가 그 수를 평생치로 읽는다. 그러면 하루치를 주는 발전소의 「지금까지 낸
|
|
393
|
+
* 양」이 오늘치가 되고, 되돌아가기 전까지 아무도 알아채지 못한다.
|
|
394
|
+
*
|
|
395
|
+
* 원본이 말했으면 그것을, 말하지 않았으면 `'unknown'` 을 세운다 — 앞서 받은 값을 지우지는 않는다
|
|
396
|
+
* (한 표본이 종류를 빠뜨렸다고 아는 사실을 잃을 이유가 없다).
|
|
397
|
+
*/
|
|
398
|
+
if (accumulation)
|
|
399
|
+
eq.generatedKWhAccumulation = accumulation;
|
|
400
|
+
else if (eq.generatedKWhAccumulation === undefined)
|
|
401
|
+
eq.generatedKWhAccumulation = 'unknown';
|
|
353
402
|
eq.generatedKWh = kWh;
|
|
354
403
|
eq.measuredAt = new Date(atMs).toISOString();
|
|
404
|
+
this.closeGenerationPeriod(id, eq, { atMs, kWh, dayIndex, kind, since, priorSince, periodAdvanced, faulted: dropped && sameDayDrop });
|
|
355
405
|
this.revision++;
|
|
356
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* **기간이 끝났으면 그 기간의 발전량을 사실로 낸다.**
|
|
409
|
+
*
|
|
410
|
+
* ── 없던 것 (2026-08-28) ────────────────────────────────────────────────────
|
|
411
|
+
* 적산값을 상태에 세우고 끝이었다. 그래서 「어제 얼마 냈나」·「이번 달 성능비」에 답할 자리가 없었고,
|
|
412
|
+
* 매일 자정에 값이 되돌아가는 원본에서는 그 하루의 총량이 **버려졌다** — 우리가 마지막으로 본 값이
|
|
413
|
+
* 곧 그날의 총량인데도.
|
|
414
|
+
*
|
|
415
|
+
* ISO 50001 의 성과지표와 기준선은 기간당 에너지로 정의된다(§`domain-catalog` 에너지 표준). 적산
|
|
416
|
+
* 하나로는 계산할 수 없다.
|
|
417
|
+
*
|
|
418
|
+
* ── 경계는 둘 중 하나에서 온다 ──────────────────────────────────────────────
|
|
419
|
+
* 원본의 기준점이 나아갔다 그 시각이 앞 기간의 끝이다 → boundary 'declared'
|
|
420
|
+
* 현장의 자정을 지났다 `utcOffsetMinutes` 가 정한 자정 → boundary 'offset'
|
|
421
|
+
*
|
|
422
|
+
* ── 총량을 얻는 방법이 종류마다 다르다 ──────────────────────────────────────
|
|
423
|
+
* 'daily' 마지막으로 본 값이 그대로 그날의 총량이다
|
|
424
|
+
* 'lifetime' 그날 처음 본 값과의 차이
|
|
425
|
+
* 'monthly'·'billing' 원본이 기준점을 말했을 때만 마감한다 — 자정으로는 그 기간이 끝나지 않는다
|
|
426
|
+
* 'unknown' 마감하지 않는다. 어느 기간의 값인지 모르는 수를 기간 사실로 낼 수 없다
|
|
427
|
+
*
|
|
428
|
+
* 관측 구간(`observedFrom`·`observedTo`)을 함께 낸다. 기간의 끝은 자정인데 마지막 표본이 저녁이었다면
|
|
429
|
+
* 그 사이는 재지 않은 것이고, 재지 않은 것을 0 으로 적으면 우리가 현장의 판단을 대신하는 것이 된다.
|
|
430
|
+
*/
|
|
431
|
+
closeGenerationPeriod(id, eq, at) {
|
|
432
|
+
const obs = this.generationPeriodObs.get(id);
|
|
433
|
+
const fresh = { dayIndex: at.dayIndex, fromKWh: at.kWh, fromMs: at.atMs, lastKWh: at.kWh, lastMs: at.atMs };
|
|
434
|
+
/* 계기가 되돌아간 뒤의 기준점은 쓸 수 없다 — 차이가 음수가 되거나, 더 나쁘게 작아진 총량이 참인 척한다. */
|
|
435
|
+
if (at.faulted || !obs || at.kind === undefined || at.kind === 'unknown') {
|
|
436
|
+
this.generationPeriodObs.set(id, fresh);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const declaredClose = at.periodAdvanced && !!at.priorSince && !!at.since;
|
|
440
|
+
const dayClose = at.dayIndex > obs.dayIndex && (at.kind === 'daily' || at.kind === 'lifetime');
|
|
441
|
+
let startMs;
|
|
442
|
+
let endMs;
|
|
443
|
+
let boundary;
|
|
444
|
+
if (declaredClose && at.kind !== 'lifetime') {
|
|
445
|
+
/* 평생 적산에 기준점이 나아가는 것은 뜻이 서지 않는다(기간이 하루가 아니다) — 자정 마감만 쓴다. */
|
|
446
|
+
startMs = Date.parse(at.priorSince);
|
|
447
|
+
endMs = Date.parse(at.since);
|
|
448
|
+
boundary = 'declared';
|
|
449
|
+
}
|
|
450
|
+
else if (dayClose) {
|
|
451
|
+
startMs = this.dayStartMs(obs.dayIndex);
|
|
452
|
+
endMs = this.dayStartMs(obs.dayIndex + 1);
|
|
453
|
+
boundary = 'offset';
|
|
454
|
+
}
|
|
455
|
+
if (startMs === undefined || endMs === undefined || boundary === undefined || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
|
456
|
+
/*
|
|
457
|
+
* 하루가 지났는데 마감할 근거가 없다 — 월·요금기간 종류인데 기준점이 오지 않는 경우다. 세어 둔다:
|
|
458
|
+
* 세지 않으면 그 발전소의 발전량이 아무 표시 없이 영원히 나오지 않는다.
|
|
459
|
+
*/
|
|
460
|
+
if (at.dayIndex > obs.dayIndex && (at.kind === 'monthly' || at.kind === 'billing'))
|
|
461
|
+
this.unclosedGenerationPeriods++;
|
|
462
|
+
if (at.dayIndex !== obs.dayIndex)
|
|
463
|
+
this.generationPeriodObs.set(id, fresh);
|
|
464
|
+
else
|
|
465
|
+
this.generationPeriodObs.set(id, { ...obs, lastKWh: at.kWh, lastMs: at.atMs });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const total = at.kind === 'lifetime' ? obs.lastKWh - obs.fromKWh : obs.lastKWh;
|
|
469
|
+
if (Number.isFinite(total) && total >= 0) {
|
|
470
|
+
this.emitOp(ENERGY_EVENT.generationPeriod, {
|
|
471
|
+
equipmentId: id,
|
|
472
|
+
kWh: total,
|
|
473
|
+
periodStart: new Date(startMs).toISOString(),
|
|
474
|
+
periodEnd: new Date(endMs).toISOString(),
|
|
475
|
+
observedFrom: new Date(obs.fromMs).toISOString(),
|
|
476
|
+
observedTo: new Date(obs.lastMs).toISOString(),
|
|
477
|
+
accumulation: at.kind,
|
|
478
|
+
boundary
|
|
479
|
+
});
|
|
480
|
+
eq.generatedKWhLastPeriod = total;
|
|
481
|
+
eq.generatedKWhLastPeriodEnd = new Date(endMs).toISOString();
|
|
482
|
+
/* 상태만 읽는 화면이 덜 잰 값을 온전한 값으로 읽지 않게, 관측 구간을 함께 남긴다. */
|
|
483
|
+
eq.generatedKWhLastPeriodObservedFrom = new Date(obs.fromMs).toISOString();
|
|
484
|
+
eq.generatedKWhLastPeriodObservedTo = new Date(obs.lastMs).toISOString();
|
|
485
|
+
}
|
|
486
|
+
this.generationPeriodObs.set(id, fresh);
|
|
487
|
+
}
|
|
488
|
+
/* 자정의 위치는 트윈 정의가 선언한다 — 커널이 짐작하지 않는다(§`localDayIndexAt`). */
|
|
489
|
+
localDayIndex(atMs) {
|
|
490
|
+
return localDayIndexAt(atMs, this.boardDef?.utcOffsetMinutes);
|
|
491
|
+
}
|
|
492
|
+
dayStartMs(dayIndex) {
|
|
493
|
+
return localDayStartMs(dayIndex, this.boardDef?.utcOffsetMinutes);
|
|
494
|
+
}
|
|
357
495
|
/** 설비마다 발전 적산을 마지막으로 들은 시각 — 늦게 온 옛 표본을 가리는 데만 쓴다. */
|
|
358
496
|
generationHeardAtMs = new Map();
|
|
497
|
+
/**
|
|
498
|
+
* 설비마다 **지금 기간의 관측** — 기간 발전량을 확정할 때 쓰는 기준점(§`closeGenerationPeriod`).
|
|
499
|
+
*
|
|
500
|
+
* **스냅샷에 낸다**(`EnergyState.generationPeriods`). 처음에는 「셈을 위한 값이니 상태에 두지 않는다」고
|
|
501
|
+
* 두었는데 그것이 틀렸다 — 재기동하면 평생 적산의 기준점이 사라지고, 그날의 발전량이 부팅 이후로만
|
|
502
|
+
* 잡힌다. 계량 지점이 `kWhAtWindowStart` 를 상태에 두는 것과 같은 이유다.
|
|
503
|
+
*/
|
|
504
|
+
generationPeriodObs = new Map();
|
|
505
|
+
/** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
|
|
506
|
+
unclosedGenerationPeriods = 0;
|
|
359
507
|
applyEquipmentEnergy(envelope) {
|
|
360
508
|
const d = envelope.data;
|
|
361
509
|
const id = String(d?.equipmentId ?? '').trim();
|
|
@@ -1128,6 +1276,26 @@ export class EmsKernel extends FlowEngine {
|
|
|
1128
1276
|
this.closedTotal = Number(e.closedTotal);
|
|
1129
1277
|
if (e.peakSince && Number.isFinite(e.peakSince.kW))
|
|
1130
1278
|
this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
|
|
1279
|
+
/*
|
|
1280
|
+
* 발전 기준점을 이어받는다 — 이것이 없으면 재기동한 날의 발전량이 부팅 이후로만 잡힌다(평생 적산은
|
|
1281
|
+
* 그날 처음 본 값과의 차이이므로). 계량 지점의 `kWhAtWindowStart` 와 같은 이유다.
|
|
1282
|
+
*/
|
|
1283
|
+
for (const o of e.generationPeriods ?? []) {
|
|
1284
|
+
if (!o?.equipmentId)
|
|
1285
|
+
continue;
|
|
1286
|
+
if (![o.dayIndex, o.fromKWh, o.fromMs, o.lastKWh, o.lastMs].every(v => Number.isFinite(Number(v))))
|
|
1287
|
+
continue;
|
|
1288
|
+
this.generationPeriodObs.set(o.equipmentId, {
|
|
1289
|
+
dayIndex: Number(o.dayIndex),
|
|
1290
|
+
fromKWh: Number(o.fromKWh),
|
|
1291
|
+
fromMs: Number(o.fromMs),
|
|
1292
|
+
lastKWh: Number(o.lastKWh),
|
|
1293
|
+
lastMs: Number(o.lastMs)
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
/* 못 마감한 수도 이어받는다 — 이어받지 않으면 재기동마다 0 으로 돌아가 문제가 사라진 것처럼 보인다. */
|
|
1297
|
+
if (Number.isFinite(e.unclosedGenerationPeriods))
|
|
1298
|
+
this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
|
|
1131
1299
|
}
|
|
1132
1300
|
getSnapshot() {
|
|
1133
1301
|
const snap = super.getSnapshot();
|
|
@@ -1146,6 +1314,12 @@ export class EmsKernel extends FlowEngine {
|
|
|
1146
1314
|
closedTotal: this.closedTotal,
|
|
1147
1315
|
...(this.peak ? { peakSince: { ...this.peak } } : {}),
|
|
1148
1316
|
...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
|
|
1317
|
+
...(this.generationPeriodObs.size
|
|
1318
|
+
? {
|
|
1319
|
+
generationPeriods: [...this.generationPeriodObs.entries()].map(([equipmentId, o]) => ({ equipmentId, ...o }))
|
|
1320
|
+
}
|
|
1321
|
+
: {}),
|
|
1322
|
+
...(this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {}),
|
|
1149
1323
|
...(contractKW !== undefined ? { contractKW } : {}),
|
|
1150
1324
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
1151
1325
|
...(this.flowRequests.size
|
package/dist/energy-ingest.d.ts
CHANGED
|
@@ -76,6 +76,16 @@ export interface EnergyGenerationRecord {
|
|
|
76
76
|
* 옮기는 것은 지어내기가 아니다 — 그때 현장의 선언된 시간대로 그 구간의 시작을 적는다.
|
|
77
77
|
*/
|
|
78
78
|
kWhSince?: string;
|
|
79
|
+
/**
|
|
80
|
+
* **어떤 누적인가** — 원본이 말하지 않으면 `'unknown'` 을 싣는다 (2026-08-28).
|
|
81
|
+
*
|
|
82
|
+
* 비우지 말고 `'unknown'` 을 실으라는 뜻이 아니다: 비워도 커널이 `'unknown'` 으로 둔다. 다만
|
|
83
|
+
* **모른다는 것을 아는 커넥터는 그것을 말하는 편이 낫다** — 그러면 「아직 안 붙였다」와 「원본이 말해
|
|
84
|
+
* 주지 않는다」가 갈린다.
|
|
85
|
+
*
|
|
86
|
+
* 값의 목록과 뜻은 `EnergyGeneratedData.accumulation` 에 있다.
|
|
87
|
+
*/
|
|
88
|
+
kWhAccumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
|
|
79
89
|
at?: string;
|
|
80
90
|
}
|
|
81
91
|
/** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
|
package/dist/energy-ingest.js
CHANGED
|
@@ -96,7 +96,22 @@ export function isEnergyEquipmentRecord(record) {
|
|
|
96
96
|
if (!record || typeof record !== 'object')
|
|
97
97
|
return false;
|
|
98
98
|
const r = record;
|
|
99
|
-
|
|
99
|
+
/*
|
|
100
|
+
* ── **발전 적산을 밀어낸다** (2026-08-28 · 인티그레이션 레인이 걸어 보고 찾았다) ─
|
|
101
|
+
*
|
|
102
|
+
* 이 판정이 `equipmentId` 만 보아서 **발전 적산 레코드에도 참이었다.** 그래서 호스트가 설비 상태
|
|
103
|
+
* 필터를 먼저 돌리면 적산이 이 문으로 들어오고, 이 문은 「바꿀 상태가 하나도 없다」고 거부한다 —
|
|
104
|
+
* **엉뚱한 이유로 거부되고 커넥터는 보냈다고 믿는다.**
|
|
105
|
+
*
|
|
106
|
+
* 호스트가 순서로 풀 수 있지만 순서는 다음 사람이 줄을 옮기면 깨진다. 다른 어휘를 밀어내는 규율은
|
|
107
|
+
* 발전 쪽이 이미 갖고 있었다(`meterId`·`epc` 를 본다) — 이쪽만 없었다. **판정끼리 배타로 만든다.**
|
|
108
|
+
*/
|
|
109
|
+
return (typeof r.equipmentId === 'string' &&
|
|
110
|
+
r.equipmentId.trim().length > 0 &&
|
|
111
|
+
r.epc === undefined &&
|
|
112
|
+
r.meterId === undefined &&
|
|
113
|
+
/* 적산을 실었으면 발전이다 — 이 문의 값은 상태이고, 적산은 자기 문이 있다. */
|
|
114
|
+
r.kWh === undefined);
|
|
100
115
|
}
|
|
101
116
|
/**
|
|
102
117
|
* 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
|
|
@@ -221,6 +236,20 @@ export function ingestEnergyGenerationRecords(records, opts) {
|
|
|
221
236
|
const equipmentId = String(r?.equipmentId ?? '').trim();
|
|
222
237
|
if (!equipmentId)
|
|
223
238
|
errors.push('equipmentId 가 없다 — 어느 설비가 만든 것인지 지어낼 수 없다');
|
|
239
|
+
/*
|
|
240
|
+
* ── **한 레코드가 두 사실을 실어 오면 거부한다** (2026-08-28) ────────────────
|
|
241
|
+
*
|
|
242
|
+
* 판정을 배타로 만들면서 생긴 자리다. 적산을 실었으면 이 문으로 오는데, 그 레코드에 상태 값
|
|
243
|
+
* (`generatedKW`·`soc`·`position` …)이 함께 있으면 **그 사실이 조용히 사라진다** — 이 문은 상태를
|
|
244
|
+
* 읽지 않기 때문이다.
|
|
245
|
+
*
|
|
246
|
+
* 잃는 것보다 거부하는 편이 낫다. 커넥터가 갈라 보내면 둘 다 산다.
|
|
247
|
+
*/
|
|
248
|
+
const STATE_FIELDS = ['generatedKW', 'exportKW', 'soc', 'chargeKW', 'dischargeKW', 'curtailable', 'minKW', 'position'];
|
|
249
|
+
const alsoState = STATE_FIELDS.filter(f => r?.[f] !== undefined);
|
|
250
|
+
if (alsoState.length) {
|
|
251
|
+
errors.push(`적산과 설비 상태를 한 레코드에 실었다(${alsoState.join('·')}) — 이 문은 적산만 읽으므로 그 값이 사라진다. 갈라 보낼 것`);
|
|
252
|
+
}
|
|
224
253
|
const raw = r?.kWh;
|
|
225
254
|
const kWh = Number(raw);
|
|
226
255
|
if (raw === undefined || raw === null || !Number.isFinite(kWh)) {
|
|
@@ -251,11 +280,31 @@ export function ingestEnergyGenerationRecords(records, opts) {
|
|
|
251
280
|
else
|
|
252
281
|
since = text;
|
|
253
282
|
}
|
|
283
|
+
/*
|
|
284
|
+
* 누적의 종류 — 아는 목록 밖의 값은 거부한다. 뭉개서 받으면 「모른다」와 「틀리게 말했다」가 같아진다.
|
|
285
|
+
*/
|
|
286
|
+
const ACCUMULATIONS = ['lifetime', 'daily', 'monthly', 'billing', 'unknown'];
|
|
287
|
+
let accumulation;
|
|
288
|
+
const rawAcc = r?.kWhAccumulation;
|
|
289
|
+
if (rawAcc !== undefined && rawAcc !== null && String(rawAcc).trim()) {
|
|
290
|
+
const text = String(rawAcc).trim();
|
|
291
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
292
|
+
errors.push(`kWhAccumulation 이 아는 값이 아니다(${ACCUMULATIONS.join('·')}): ${JSON.stringify(rawAcc)}`);
|
|
293
|
+
}
|
|
294
|
+
else
|
|
295
|
+
accumulation = text;
|
|
296
|
+
}
|
|
254
297
|
if (errors.length) {
|
|
255
298
|
rejected.push({ record: r, errors });
|
|
256
299
|
continue;
|
|
257
300
|
}
|
|
258
|
-
const data = {
|
|
301
|
+
const data = {
|
|
302
|
+
equipmentId,
|
|
303
|
+
kWh,
|
|
304
|
+
at: eventTime,
|
|
305
|
+
...(since ? { since } : {}),
|
|
306
|
+
...(accumulation ? { accumulation } : {})
|
|
307
|
+
};
|
|
259
308
|
accepted.push({
|
|
260
309
|
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
261
310
|
eventType: ENERGY_EVENT.generated,
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -146,6 +146,8 @@ __export(index_exports, {
|
|
|
146
146
|
judgeAgainstSpec: () => judgeAgainstSpec,
|
|
147
147
|
levelOfLocationType: () => levelOfLocationType,
|
|
148
148
|
lgtinClass: () => lgtinClass,
|
|
149
|
+
localDayIndexAt: () => localDayIndexAt,
|
|
150
|
+
localDayStartMs: () => localDayStartMs,
|
|
149
151
|
locationStatusOf: () => locationStatusOf,
|
|
150
152
|
lotFromAttributes: () => lotFromAttributes,
|
|
151
153
|
mapRecord: () => mapRecord,
|
|
@@ -461,6 +463,12 @@ function onDeclaredDay(e, minuteOfDay, weekday) {
|
|
|
461
463
|
function weekdayAt(ms2, utcOffsetMinutes) {
|
|
462
464
|
return new Date(ms2 + (utcOffsetMinutes ?? 0) * 6e4).getUTCDay();
|
|
463
465
|
}
|
|
466
|
+
function localDayIndexAt(ms2, utcOffsetMinutes) {
|
|
467
|
+
return Math.floor((ms2 + (utcOffsetMinutes ?? 0) * 6e4) / 864e5);
|
|
468
|
+
}
|
|
469
|
+
function localDayStartMs(dayIndex, utcOffsetMinutes) {
|
|
470
|
+
return dayIndex * 864e5 - (utcOffsetMinutes ?? 0) * 6e4;
|
|
471
|
+
}
|
|
464
472
|
function coversInstant(e, atMs) {
|
|
465
473
|
const from = e.startDateTime ? Date.parse(e.startDateTime) : NaN;
|
|
466
474
|
const to = e.finishDateTime ? Date.parse(e.finishDateTime) : NaN;
|
|
@@ -695,6 +703,16 @@ var ENERGY_EVENT = {
|
|
|
695
703
|
tariffShift: "energy.tariff.shift",
|
|
696
704
|
/** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
|
|
697
705
|
generated: "energy.generated",
|
|
706
|
+
/**
|
|
707
|
+
* **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
|
|
708
|
+
*
|
|
709
|
+
* ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
|
|
710
|
+
* 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
|
|
711
|
+
*
|
|
712
|
+
* 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
|
|
713
|
+
* 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
|
|
714
|
+
*/
|
|
715
|
+
generationPeriod: "energy.generation.period",
|
|
698
716
|
/** 저장(ESS 충전). */
|
|
699
717
|
stored: "energy.stored",
|
|
700
718
|
/** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
|
|
@@ -2710,7 +2728,7 @@ var CAPABILITIES = {
|
|
|
2710
2728
|
* 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
|
|
2711
2729
|
* 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
|
|
2712
2730
|
*/
|
|
2713
|
-
stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt", "generatedKWhSince", "generatedKWhBasis"],
|
|
2731
|
+
stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt", "generatedKWhSince", "generatedKWhBasis", "generatedKWhAccumulation", "generatedKWhLastPeriod", "generatedKWhLastPeriodEnd", "generatedKWhLastPeriodObservedFrom", "generatedKWhLastPeriodObservedTo"],
|
|
2714
2732
|
models: ["GeneratingState"],
|
|
2715
2733
|
results: ["generated"]
|
|
2716
2734
|
},
|
|
@@ -7930,18 +7948,116 @@ var EmsKernel = class extends FlowEngine {
|
|
|
7930
7948
|
const priorSince = eq.generatedKWhSince;
|
|
7931
7949
|
const since = typeof d?.since === "string" && d.since.trim() ? d.since.trim() : void 0;
|
|
7932
7950
|
const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
|
|
7933
|
-
|
|
7951
|
+
const sameSince = !!since && !!priorSince && Date.parse(since) === Date.parse(priorSince);
|
|
7952
|
+
const accumulation = typeof d?.accumulation === "string" ? d.accumulation : void 0;
|
|
7953
|
+
const kind = accumulation ?? eq.generatedKWhAccumulation;
|
|
7954
|
+
const periodic = kind === "daily" || kind === "monthly" || kind === "billing";
|
|
7955
|
+
const dropped = Number.isFinite(before) && kWh < before;
|
|
7956
|
+
const obs = this.generationPeriodObs.get(id);
|
|
7957
|
+
const dayIndex = this.localDayIndex(atMs);
|
|
7958
|
+
const sameDayDrop = dropped && obs !== void 0 && obs.dayIndex === dayIndex;
|
|
7959
|
+
if (dropped && !periodAdvanced && (!periodic || sameDayDrop)) {
|
|
7934
7960
|
;
|
|
7935
7961
|
eq.generatedKWhResetAt = new Date(atMs).toISOString();
|
|
7936
|
-
eq.generatedKWhBasis =
|
|
7962
|
+
eq.generatedKWhBasis = sameSince ? "declared" : "inferred";
|
|
7937
7963
|
}
|
|
7938
7964
|
if (since) eq.generatedKWhSince = since;
|
|
7965
|
+
if (accumulation) eq.generatedKWhAccumulation = accumulation;
|
|
7966
|
+
else if (eq.generatedKWhAccumulation === void 0) eq.generatedKWhAccumulation = "unknown";
|
|
7939
7967
|
eq.generatedKWh = kWh;
|
|
7940
7968
|
eq.measuredAt = new Date(atMs).toISOString();
|
|
7969
|
+
this.closeGenerationPeriod(id, eq, { atMs, kWh, dayIndex, kind, since, priorSince, periodAdvanced, faulted: dropped && sameDayDrop });
|
|
7941
7970
|
this.revision++;
|
|
7942
7971
|
}
|
|
7972
|
+
/**
|
|
7973
|
+
* **기간이 끝났으면 그 기간의 발전량을 사실로 낸다.**
|
|
7974
|
+
*
|
|
7975
|
+
* ── 없던 것 (2026-08-28) ────────────────────────────────────────────────────
|
|
7976
|
+
* 적산값을 상태에 세우고 끝이었다. 그래서 「어제 얼마 냈나」·「이번 달 성능비」에 답할 자리가 없었고,
|
|
7977
|
+
* 매일 자정에 값이 되돌아가는 원본에서는 그 하루의 총량이 **버려졌다** — 우리가 마지막으로 본 값이
|
|
7978
|
+
* 곧 그날의 총량인데도.
|
|
7979
|
+
*
|
|
7980
|
+
* ISO 50001 의 성과지표와 기준선은 기간당 에너지로 정의된다(§`domain-catalog` 에너지 표준). 적산
|
|
7981
|
+
* 하나로는 계산할 수 없다.
|
|
7982
|
+
*
|
|
7983
|
+
* ── 경계는 둘 중 하나에서 온다 ──────────────────────────────────────────────
|
|
7984
|
+
* 원본의 기준점이 나아갔다 그 시각이 앞 기간의 끝이다 → boundary 'declared'
|
|
7985
|
+
* 현장의 자정을 지났다 `utcOffsetMinutes` 가 정한 자정 → boundary 'offset'
|
|
7986
|
+
*
|
|
7987
|
+
* ── 총량을 얻는 방법이 종류마다 다르다 ──────────────────────────────────────
|
|
7988
|
+
* 'daily' 마지막으로 본 값이 그대로 그날의 총량이다
|
|
7989
|
+
* 'lifetime' 그날 처음 본 값과의 차이
|
|
7990
|
+
* 'monthly'·'billing' 원본이 기준점을 말했을 때만 마감한다 — 자정으로는 그 기간이 끝나지 않는다
|
|
7991
|
+
* 'unknown' 마감하지 않는다. 어느 기간의 값인지 모르는 수를 기간 사실로 낼 수 없다
|
|
7992
|
+
*
|
|
7993
|
+
* 관측 구간(`observedFrom`·`observedTo`)을 함께 낸다. 기간의 끝은 자정인데 마지막 표본이 저녁이었다면
|
|
7994
|
+
* 그 사이는 재지 않은 것이고, 재지 않은 것을 0 으로 적으면 우리가 현장의 판단을 대신하는 것이 된다.
|
|
7995
|
+
*/
|
|
7996
|
+
closeGenerationPeriod(id, eq, at) {
|
|
7997
|
+
const obs = this.generationPeriodObs.get(id);
|
|
7998
|
+
const fresh = { dayIndex: at.dayIndex, fromKWh: at.kWh, fromMs: at.atMs, lastKWh: at.kWh, lastMs: at.atMs };
|
|
7999
|
+
if (at.faulted || !obs || at.kind === void 0 || at.kind === "unknown") {
|
|
8000
|
+
this.generationPeriodObs.set(id, fresh);
|
|
8001
|
+
return;
|
|
8002
|
+
}
|
|
8003
|
+
const declaredClose = at.periodAdvanced && !!at.priorSince && !!at.since;
|
|
8004
|
+
const dayClose = at.dayIndex > obs.dayIndex && (at.kind === "daily" || at.kind === "lifetime");
|
|
8005
|
+
let startMs;
|
|
8006
|
+
let endMs;
|
|
8007
|
+
let boundary;
|
|
8008
|
+
if (declaredClose && at.kind !== "lifetime") {
|
|
8009
|
+
startMs = Date.parse(at.priorSince);
|
|
8010
|
+
endMs = Date.parse(at.since);
|
|
8011
|
+
boundary = "declared";
|
|
8012
|
+
} else if (dayClose) {
|
|
8013
|
+
startMs = this.dayStartMs(obs.dayIndex);
|
|
8014
|
+
endMs = this.dayStartMs(obs.dayIndex + 1);
|
|
8015
|
+
boundary = "offset";
|
|
8016
|
+
}
|
|
8017
|
+
if (startMs === void 0 || endMs === void 0 || boundary === void 0 || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
|
8018
|
+
if (at.dayIndex > obs.dayIndex && (at.kind === "monthly" || at.kind === "billing")) this.unclosedGenerationPeriods++;
|
|
8019
|
+
if (at.dayIndex !== obs.dayIndex) this.generationPeriodObs.set(id, fresh);
|
|
8020
|
+
else this.generationPeriodObs.set(id, { ...obs, lastKWh: at.kWh, lastMs: at.atMs });
|
|
8021
|
+
return;
|
|
8022
|
+
}
|
|
8023
|
+
const total = at.kind === "lifetime" ? obs.lastKWh - obs.fromKWh : obs.lastKWh;
|
|
8024
|
+
if (Number.isFinite(total) && total >= 0) {
|
|
8025
|
+
this.emitOp(ENERGY_EVENT.generationPeriod, {
|
|
8026
|
+
equipmentId: id,
|
|
8027
|
+
kWh: total,
|
|
8028
|
+
periodStart: new Date(startMs).toISOString(),
|
|
8029
|
+
periodEnd: new Date(endMs).toISOString(),
|
|
8030
|
+
observedFrom: new Date(obs.fromMs).toISOString(),
|
|
8031
|
+
observedTo: new Date(obs.lastMs).toISOString(),
|
|
8032
|
+
accumulation: at.kind,
|
|
8033
|
+
boundary
|
|
8034
|
+
});
|
|
8035
|
+
eq.generatedKWhLastPeriod = total;
|
|
8036
|
+
eq.generatedKWhLastPeriodEnd = new Date(endMs).toISOString();
|
|
8037
|
+
eq.generatedKWhLastPeriodObservedFrom = new Date(obs.fromMs).toISOString();
|
|
8038
|
+
eq.generatedKWhLastPeriodObservedTo = new Date(obs.lastMs).toISOString();
|
|
8039
|
+
}
|
|
8040
|
+
this.generationPeriodObs.set(id, fresh);
|
|
8041
|
+
}
|
|
8042
|
+
/* 자정의 위치는 트윈 정의가 선언한다 — 커널이 짐작하지 않는다(§`localDayIndexAt`). */
|
|
8043
|
+
localDayIndex(atMs) {
|
|
8044
|
+
return localDayIndexAt(atMs, this.boardDef?.utcOffsetMinutes);
|
|
8045
|
+
}
|
|
8046
|
+
dayStartMs(dayIndex) {
|
|
8047
|
+
return localDayStartMs(dayIndex, this.boardDef?.utcOffsetMinutes);
|
|
8048
|
+
}
|
|
7943
8049
|
/** 설비마다 발전 적산을 마지막으로 들은 시각 — 늦게 온 옛 표본을 가리는 데만 쓴다. */
|
|
7944
8050
|
generationHeardAtMs = /* @__PURE__ */ new Map();
|
|
8051
|
+
/**
|
|
8052
|
+
* 설비마다 **지금 기간의 관측** — 기간 발전량을 확정할 때 쓰는 기준점(§`closeGenerationPeriod`).
|
|
8053
|
+
*
|
|
8054
|
+
* **스냅샷에 낸다**(`EnergyState.generationPeriods`). 처음에는 「셈을 위한 값이니 상태에 두지 않는다」고
|
|
8055
|
+
* 두었는데 그것이 틀렸다 — 재기동하면 평생 적산의 기준점이 사라지고, 그날의 발전량이 부팅 이후로만
|
|
8056
|
+
* 잡힌다. 계량 지점이 `kWhAtWindowStart` 를 상태에 두는 것과 같은 이유다.
|
|
8057
|
+
*/
|
|
8058
|
+
generationPeriodObs = /* @__PURE__ */ new Map();
|
|
8059
|
+
/** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
|
|
8060
|
+
unclosedGenerationPeriods = 0;
|
|
7945
8061
|
applyEquipmentEnergy(envelope) {
|
|
7946
8062
|
const d = envelope.data;
|
|
7947
8063
|
const id = String(d?.equipmentId ?? "").trim();
|
|
@@ -8483,6 +8599,18 @@ var EmsKernel = class extends FlowEngine {
|
|
|
8483
8599
|
if (e.closed?.length) this.closed = e.closed.map((w) => ({ ...w })).slice(-KEEP_CLOSED);
|
|
8484
8600
|
if (Number.isFinite(e.closedTotal)) this.closedTotal = Number(e.closedTotal);
|
|
8485
8601
|
if (e.peakSince && Number.isFinite(e.peakSince.kW)) this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
|
|
8602
|
+
for (const o of e.generationPeriods ?? []) {
|
|
8603
|
+
if (!o?.equipmentId) continue;
|
|
8604
|
+
if (![o.dayIndex, o.fromKWh, o.fromMs, o.lastKWh, o.lastMs].every((v) => Number.isFinite(Number(v)))) continue;
|
|
8605
|
+
this.generationPeriodObs.set(o.equipmentId, {
|
|
8606
|
+
dayIndex: Number(o.dayIndex),
|
|
8607
|
+
fromKWh: Number(o.fromKWh),
|
|
8608
|
+
fromMs: Number(o.fromMs),
|
|
8609
|
+
lastKWh: Number(o.lastKWh),
|
|
8610
|
+
lastMs: Number(o.lastMs)
|
|
8611
|
+
});
|
|
8612
|
+
}
|
|
8613
|
+
if (Number.isFinite(e.unclosedGenerationPeriods)) this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
|
|
8486
8614
|
}
|
|
8487
8615
|
getSnapshot() {
|
|
8488
8616
|
const snap = super.getSnapshot();
|
|
@@ -8499,6 +8627,10 @@ var EmsKernel = class extends FlowEngine {
|
|
|
8499
8627
|
closedTotal: this.closedTotal,
|
|
8500
8628
|
...this.peak ? { peakSince: { ...this.peak } } : {},
|
|
8501
8629
|
...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
|
|
8630
|
+
...this.generationPeriodObs.size ? {
|
|
8631
|
+
generationPeriods: [...this.generationPeriodObs.entries()].map(([equipmentId, o]) => ({ equipmentId, ...o }))
|
|
8632
|
+
} : {},
|
|
8633
|
+
...this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {},
|
|
8502
8634
|
...contractKW !== void 0 ? { contractKW } : {},
|
|
8503
8635
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
8504
8636
|
...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
|
|
@@ -9352,7 +9484,8 @@ var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"
|
|
|
9352
9484
|
function isEnergyEquipmentRecord(record) {
|
|
9353
9485
|
if (!record || typeof record !== "object") return false;
|
|
9354
9486
|
const r = record;
|
|
9355
|
-
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0
|
|
9487
|
+
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0 && /* 적산을 실었으면 발전이다 — 이 문의 값은 상태이고, 적산은 자기 문이 있다. */
|
|
9488
|
+
r.kWh === void 0;
|
|
9356
9489
|
}
|
|
9357
9490
|
function ingestEnergyEquipmentRecords(records, opts) {
|
|
9358
9491
|
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
@@ -9446,6 +9579,13 @@ function ingestEnergyGenerationRecords(records, opts) {
|
|
|
9446
9579
|
const errors = [];
|
|
9447
9580
|
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
9448
9581
|
if (!equipmentId) errors.push("equipmentId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC124\uBE44\uAC00 \uB9CC\uB4E0 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
9582
|
+
const STATE_FIELDS = ["generatedKW", "exportKW", "soc", "chargeKW", "dischargeKW", "curtailable", "minKW", "position"];
|
|
9583
|
+
const alsoState = STATE_FIELDS.filter((f) => r?.[f] !== void 0);
|
|
9584
|
+
if (alsoState.length) {
|
|
9585
|
+
errors.push(
|
|
9586
|
+
`\uC801\uC0B0\uACFC \uC124\uBE44 \uC0C1\uD0DC\uB97C \uD55C \uB808\uCF54\uB4DC\uC5D0 \uC2E4\uC5C8\uB2E4(${alsoState.join("\xB7")}) \u2014 \uC774 \uBB38\uC740 \uC801\uC0B0\uB9CC \uC77D\uC73C\uBBC0\uB85C \uADF8 \uAC12\uC774 \uC0AC\uB77C\uC9C4\uB2E4. \uAC08\uB77C \uBCF4\uB0BC \uAC83`
|
|
9587
|
+
);
|
|
9588
|
+
}
|
|
9449
9589
|
const raw = r?.kWh;
|
|
9450
9590
|
const kWh = Number(raw);
|
|
9451
9591
|
if (raw === void 0 || raw === null || !Number.isFinite(kWh)) {
|
|
@@ -9465,11 +9605,26 @@ function ingestEnergyGenerationRecords(records, opts) {
|
|
|
9465
9605
|
errors.push(`kWhSince(${text}) \uAC00 \uC7B0 \uC2DC\uAC01(${eventTime})\uBCF4\uB2E4 \uB4A4\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4`);
|
|
9466
9606
|
} else since = text;
|
|
9467
9607
|
}
|
|
9608
|
+
const ACCUMULATIONS = ["lifetime", "daily", "monthly", "billing", "unknown"];
|
|
9609
|
+
let accumulation;
|
|
9610
|
+
const rawAcc = r?.kWhAccumulation;
|
|
9611
|
+
if (rawAcc !== void 0 && rawAcc !== null && String(rawAcc).trim()) {
|
|
9612
|
+
const text = String(rawAcc).trim();
|
|
9613
|
+
if (!ACCUMULATIONS.includes(text)) {
|
|
9614
|
+
errors.push(`kWhAccumulation \uC774 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${ACCUMULATIONS.join("\xB7")}): ${JSON.stringify(rawAcc)}`);
|
|
9615
|
+
} else accumulation = text;
|
|
9616
|
+
}
|
|
9468
9617
|
if (errors.length) {
|
|
9469
9618
|
rejected.push({ record: r, errors });
|
|
9470
9619
|
continue;
|
|
9471
9620
|
}
|
|
9472
|
-
const data = {
|
|
9621
|
+
const data = {
|
|
9622
|
+
equipmentId,
|
|
9623
|
+
kWh,
|
|
9624
|
+
at: eventTime,
|
|
9625
|
+
...since ? { since } : {},
|
|
9626
|
+
...accumulation ? { accumulation } : {}
|
|
9627
|
+
};
|
|
9473
9628
|
accepted.push({
|
|
9474
9629
|
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
9475
9630
|
eventType: ENERGY_EVENT.generated,
|
|
@@ -10176,6 +10331,8 @@ function retiredVocabularyIn(line) {
|
|
|
10176
10331
|
judgeAgainstSpec,
|
|
10177
10332
|
levelOfLocationType,
|
|
10178
10333
|
lgtinClass,
|
|
10334
|
+
localDayIndexAt,
|
|
10335
|
+
localDayStartMs,
|
|
10179
10336
|
locationStatusOf,
|
|
10180
10337
|
lotFromAttributes,
|
|
10181
10338
|
mapRecord,
|
package/package.json
CHANGED