@operato/twin-kernel 0.7.68 → 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 +13 -0
- package/dist/capability.js +1 -1
- package/dist/contract.d.ts +189 -0
- package/dist/contract.js +24 -0
- package/dist/ems-kernel.d.ts +37 -0
- package/dist/ems-kernel.js +198 -3
- package/dist/energy-ingest.d.ts +21 -0
- package/dist/energy-ingest.js +69 -2
- package/dist-cjs/index.cjs +176 -5
- package/package.json +1 -1
package/dist/capability.d.ts
CHANGED
|
@@ -53,6 +53,19 @@ export interface GeneratingState {
|
|
|
53
53
|
exportKW?: number;
|
|
54
54
|
generatedKWh?: number;
|
|
55
55
|
generatedKWhResetAt?: string;
|
|
56
|
+
/** 지금 든 적산이 무엇부터 쌓인 것인가 — 원본이 말했을 때만 있다. */
|
|
57
|
+
generatedKWhSince?: string;
|
|
58
|
+
/** 되돌아간 판정이 사실인가 짐작인가 — 'declared' 또는 'inferred'. */
|
|
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;
|
|
56
69
|
}
|
|
57
70
|
/** 저장 — 담고 낸다. 충전과 방전을 나눈다(손실·수명 판단이 둘을 구별해야 한다). */
|
|
58
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'], 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`)와 **한 번뿐인 구간**(휴일·정비창)을 함께 본다.
|
|
@@ -1378,6 +1388,62 @@ export interface EquipmentState extends EffectivePeriod {
|
|
|
1378
1388
|
* 되돌아간 적이 없으면 이 칸이 없다.
|
|
1379
1389
|
*/
|
|
1380
1390
|
generatedKWhResetAt?: ISOTime;
|
|
1391
|
+
/**
|
|
1392
|
+
* 지금 든 적산이 **무엇부터 쌓인 것인가** — 원본이 말했을 때만 있다 (2026-08-28).
|
|
1393
|
+
*
|
|
1394
|
+
* 이 값이 있으면 차분을 구하는 쪽이 구간을 안다. 그리고 값이 줄었을 때 그것이 계기의 이상인지
|
|
1395
|
+
* (기준점 그대로) 구간이 새로 시작한 것인지(기준점이 나아갔다) 갈릴 수 있다.
|
|
1396
|
+
*/
|
|
1397
|
+
generatedKWhSince?: ISOTime;
|
|
1398
|
+
/**
|
|
1399
|
+
* `generatedKWhResetAt` 이 **사실인가 짐작인가**.
|
|
1400
|
+
*
|
|
1401
|
+
* 'declared' 기준점을 받았고 그것이 그대로인데 값이 줄었다 — 계기의 이상이다
|
|
1402
|
+
* 'inferred' 기준점을 받지 못했고 값이 줄었다 — 계기 교체인지 구간 경계인지 **모른다**
|
|
1403
|
+
*
|
|
1404
|
+
* 예전에는 둘을 구별하지 않고 언제나 「되돌아갔다」로 적었다. 그러면 하루 누적을 주는 원본에서 매일
|
|
1405
|
+
* 자정이 계기 교체로 보인다. 짐작을 사실처럼 적지 않기 위한 칸이다.
|
|
1406
|
+
*/
|
|
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;
|
|
1381
1447
|
/** storing — 충전율(%)·충전·방전(kW). 충전과 방전을 나눈다(손실·수명 판단이 그 둘을 구별한다). */
|
|
1382
1448
|
soc?: number;
|
|
1383
1449
|
chargeKW?: number;
|
|
@@ -1943,6 +2009,33 @@ export interface EnergyState {
|
|
|
1943
2009
|
hook: string;
|
|
1944
2010
|
count: number;
|
|
1945
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;
|
|
1946
2039
|
}
|
|
1947
2040
|
/**
|
|
1948
2041
|
* 조치방향 — code=안정 조치 키(언어 중립). command 있으면 원클릭 실행, 없으면 권고.
|
|
@@ -2343,6 +2436,16 @@ export declare const ENERGY_EVENT: {
|
|
|
2343
2436
|
readonly tariffShift: "energy.tariff.shift";
|
|
2344
2437
|
/** 발전(PV 등) — 역송을 포함한다(음의 소비가 아니라 별개 사실이다). */
|
|
2345
2438
|
readonly generated: "energy.generated";
|
|
2439
|
+
/**
|
|
2440
|
+
* **기간 발전량 확정** — 한 기간이 끝났고 그 기간에 얼마 냈는지가 정해졌다.
|
|
2441
|
+
*
|
|
2442
|
+
* ISO 50001 의 성과지표와 기준선, 태양광 성능비가 모두 「기간당 에너지」로 정의된다. 적산값만 상태에
|
|
2443
|
+
* 두면 그 어느 것도 계산할 수 없고, 지난 기간들을 합할 수도 없다(상태는 지금 하나만 든다).
|
|
2444
|
+
*
|
|
2445
|
+
* 계량이 수요 구간을 마감해 사실로 내는 것과 같은 짝이다(§`demandWindow`). 두 값을 더하지 말 것 —
|
|
2446
|
+
* 발전량은 설비가 만든 양이고 구간 전력량은 계량 지점을 지난 양이다.
|
|
2447
|
+
*/
|
|
2448
|
+
readonly generationPeriod: "energy.generation.period";
|
|
2346
2449
|
/** 저장(ESS 충전). */
|
|
2347
2450
|
readonly stored: "energy.stored";
|
|
2348
2451
|
/** 방전(ESS). 충전과 나눈다 — 손실·수명 판단이 둘을 구별해야 한다. */
|
|
@@ -2405,6 +2508,63 @@ export interface EnergyGeneratedData {
|
|
|
2405
2508
|
* 다르며 다를 때 맞는 쪽은 계기다(계량 쪽과 같은 규율).
|
|
2406
2509
|
*/
|
|
2407
2510
|
kWh: number;
|
|
2511
|
+
/**
|
|
2512
|
+
* **이 적산이 쌓이기 시작한 시각** — 원본이 말했을 때만 있다 (2026-08-28).
|
|
2513
|
+
*
|
|
2514
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
2515
|
+
* 계기가 주는 적산이 어느 구간의 것인지는 **원본마다 다르다** — 설치 이후·하루·한 달·요금기간.
|
|
2516
|
+
* 표준도 그것을 갈라 둔다(계측값에 구간을 붙인다). 그런데 이 문은 한 뜻만 가정하고 있었다:
|
|
2517
|
+
* 단조 증가하는 총적산. 그래서 값이 줄면 **계기 교체로 추론했다.**
|
|
2518
|
+
*
|
|
2519
|
+
* 하루 누적을 주는 원본에서는 그 추론이 매일 자정마다 참이 된다 — 실측(PPMS 인버터):
|
|
2520
|
+
*
|
|
2521
|
+
* 2026-08-25 23:37 KST acc 574.8
|
|
2522
|
+
* 2026-08-28 18:25 KST acc 165.2 ← 줄었다. 자정에 되돌아간다
|
|
2523
|
+
*
|
|
2524
|
+
* 그대로 받으면 「계기 교체가 하루에 한 번 일어난다」가 기록된다.
|
|
2525
|
+
*
|
|
2526
|
+
* ── 왜 시각인가(구간의 이름이 아니라) ───────────────────────────────────────
|
|
2527
|
+
* 한 축으로 하루·한 달·요금기간·설치 이후를 다 담고, 커널이 구간을 이어 붙일 근거도 그 시각이다.
|
|
2528
|
+
* 「요금기간」 같은 이름은 경계를 계약이 정하므로 이름만으로는 커널이 알 수 없다.
|
|
2529
|
+
*
|
|
2530
|
+
* **없으면 「모른다」다** — 설치 이후로 가정하지 않는다. 그때 커널은 지금처럼 추론하고, 그 추론이
|
|
2531
|
+
* 짐작이라는 사실을 상태에 남긴다(§`generatedKWhBasis`).
|
|
2532
|
+
*/
|
|
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';
|
|
2408
2568
|
at: ISOTime;
|
|
2409
2569
|
}
|
|
2410
2570
|
/** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
|
|
@@ -2415,11 +2575,40 @@ export interface EnergyMeasuredData {
|
|
|
2415
2575
|
kW: number;
|
|
2416
2576
|
/** 누적 전력량(kWh) — 계기 누적값. 차분은 소비처가 한다(계기 교체·리셋을 우리가 지어내지 않는다). */
|
|
2417
2577
|
kWh?: number;
|
|
2578
|
+
/**
|
|
2579
|
+
* 이 적산이 쌓이기 시작한 시각 — 원본이 말했을 때만 있다(§`EnergyGeneratedData.since`).
|
|
2580
|
+
*
|
|
2581
|
+
* 계량 쪽의 셈은 이 값 없이도 정직하다 — 적산이 줄면 그 구간의 전력량을 **내지 않는다**(음수를
|
|
2582
|
+
* 만들지 않는다). 이 축은 셈을 바꾸지 않고 **왜 비는지**를 말할 수 있게 한다: 「계기가 교체돼서」와
|
|
2583
|
+
* 「구간이 바뀌어서」는 다른 사실이고, 화면이 그 둘을 같은 빈칸으로 보이면 사람이 계기를 의심한다.
|
|
2584
|
+
*/
|
|
2585
|
+
kWhSince?: ISOTime;
|
|
2418
2586
|
/** 역률 — 없으면 모르는 것이다(1 로 채우지 않는다). */
|
|
2419
2587
|
powerFactor?: number;
|
|
2420
2588
|
at: ISOTime;
|
|
2421
2589
|
}
|
|
2422
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
|
+
}
|
|
2423
2612
|
export interface EnergyDemandWindowData {
|
|
2424
2613
|
/** 배전 구간(로케이션 id) 또는 현장 전체. */
|
|
2425
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;
|
|
@@ -322,19 +322,188 @@ export class EmsKernel extends FlowEngine {
|
|
|
322
322
|
if (heardAt !== undefined && atMs < heardAt)
|
|
323
323
|
return;
|
|
324
324
|
this.generationHeardAtMs.set(id, atMs);
|
|
325
|
+
/*
|
|
326
|
+
* ── 되돌아간 적산 — **세 갈래로 가른다** (2026-08-28) ───────────────────────
|
|
327
|
+
*
|
|
328
|
+
* 예전에는 갈래가 하나였다: 값이 줄면 계기 교체다. 그런데 **하루 누적을 주는 원본**에서는 그
|
|
329
|
+
* 판정이 매일 자정마다 참이 된다 — 실측(PPMS 인버터): 574.8 → 165.2, 자정에 되돌아간다.
|
|
330
|
+
* 그러면 「계기 교체가 하루에 한 번 일어난다」가 지난 기록에 남는다.
|
|
331
|
+
*
|
|
332
|
+
* 원본이 **기준점**을 말해 주면 그 추론이 필요 없어진다(§`EnergyGeneratedData.since`).
|
|
333
|
+
*
|
|
334
|
+
* 기준점 그대로 · 값이 줄었다 계기의 이상이다 → resetAt · basis 'declared'
|
|
335
|
+
* 기준점이 나아갔다 구간이 새로 시작했다 → resetAt 을 찍지 않는다
|
|
336
|
+
* 기준점이 없다 · 값이 줄었다 모른다 → resetAt · basis 'inferred'
|
|
337
|
+
*
|
|
338
|
+
* 셋째가 예전의 전부였다. 짐작을 사실처럼 적지 않기 위해 그것에 표를 남긴다.
|
|
339
|
+
*/
|
|
325
340
|
const before = Number(eq.generatedKWh);
|
|
326
|
-
|
|
341
|
+
const priorSince = eq.generatedKWhSince;
|
|
342
|
+
const since = typeof d?.since === 'string' && d.since.trim() ? d.since.trim() : undefined;
|
|
343
|
+
const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
|
|
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)) {
|
|
327
381
|
/* 되돌아갔다 — 값은 새것으로 두고, 그 사실을 시각으로 남긴다(우리가 계단을 메우지 않는다). */
|
|
328
382
|
;
|
|
329
383
|
eq.generatedKWhResetAt = new Date(atMs).toISOString();
|
|
384
|
+
eq.generatedKWhBasis = sameSince ? 'declared' : 'inferred';
|
|
330
385
|
}
|
|
331
|
-
|
|
386
|
+
/* 기준점은 온 것만 세운다 — 오지 않았다고 앞서 받은 것을 지우지 않는다(결측 ≠ 없음). */
|
|
387
|
+
if (since)
|
|
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';
|
|
332
402
|
eq.generatedKWh = kWh;
|
|
333
403
|
eq.measuredAt = new Date(atMs).toISOString();
|
|
404
|
+
this.closeGenerationPeriod(id, eq, { atMs, kWh, dayIndex, kind, since, priorSince, periodAdvanced, faulted: dropped && sameDayDrop });
|
|
334
405
|
this.revision++;
|
|
335
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
|
+
}
|
|
336
495
|
/** 설비마다 발전 적산을 마지막으로 들은 시각 — 늦게 온 옛 표본을 가리는 데만 쓴다. */
|
|
337
496
|
generationHeardAtMs = new Map();
|
|
497
|
+
/**
|
|
498
|
+
* 설비마다 **지금 기간의 관측** — 기간 발전량을 확정할 때 쓰는 기준점(§`closeGenerationPeriod`).
|
|
499
|
+
*
|
|
500
|
+
* **스냅샷에 낸다**(`EnergyState.generationPeriods`). 처음에는 「셈을 위한 값이니 상태에 두지 않는다」고
|
|
501
|
+
* 두었는데 그것이 틀렸다 — 재기동하면 평생 적산의 기준점이 사라지고, 그날의 발전량이 부팅 이후로만
|
|
502
|
+
* 잡힌다. 계량 지점이 `kWhAtWindowStart` 를 상태에 두는 것과 같은 이유다.
|
|
503
|
+
*/
|
|
504
|
+
generationPeriodObs = new Map();
|
|
505
|
+
/** 기준점이 없어 마감하지 못한 기간 누적의 수 — 조용히 사라지지 않게 센다. */
|
|
506
|
+
unclosedGenerationPeriods = 0;
|
|
338
507
|
applyEquipmentEnergy(envelope) {
|
|
339
508
|
const d = envelope.data;
|
|
340
509
|
const id = String(d?.equipmentId ?? '').trim();
|
|
@@ -1107,6 +1276,26 @@ export class EmsKernel extends FlowEngine {
|
|
|
1107
1276
|
this.closedTotal = Number(e.closedTotal);
|
|
1108
1277
|
if (e.peakSince && Number.isFinite(e.peakSince.kW))
|
|
1109
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);
|
|
1110
1299
|
}
|
|
1111
1300
|
getSnapshot() {
|
|
1112
1301
|
const snap = super.getSnapshot();
|
|
@@ -1125,6 +1314,12 @@ export class EmsKernel extends FlowEngine {
|
|
|
1125
1314
|
closedTotal: this.closedTotal,
|
|
1126
1315
|
...(this.peak ? { peakSince: { ...this.peak } } : {}),
|
|
1127
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 } : {}),
|
|
1128
1323
|
...(contractKW !== undefined ? { contractKW } : {}),
|
|
1129
1324
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
1130
1325
|
...(this.flowRequests.size
|
package/dist/energy-ingest.d.ts
CHANGED
|
@@ -65,6 +65,27 @@ export interface EnergyGenerationRecord {
|
|
|
65
65
|
equipmentId: string;
|
|
66
66
|
/** 계기 적산값(kWh) — 연결된 시스템이 준 값 그대로. 차분은 소비처가 한다. */
|
|
67
67
|
kWh?: number;
|
|
68
|
+
/**
|
|
69
|
+
* **이 적산이 쌓이기 시작한 시각**(ISO) — 원본이 그 뜻을 말할 때만 싣는다 (2026-08-28).
|
|
70
|
+
*
|
|
71
|
+
* 하루 누적을 주는 원본이 있다. 그 값을 총적산으로 받으면 **매일 자정이 계기 교체로 기록된다**
|
|
72
|
+
* (§`EnergyGeneratedData.since` 의 실측).
|
|
73
|
+
*
|
|
74
|
+
* **없으면 비운다** — 설치 이후라고 가정하지 않는다. 원본이 말하지 않은 것을 커넥터가 지어내지
|
|
75
|
+
* 않는 것이 이 문의 규율이다. 다만 원본의 **문서나 코드가 그 뜻을 말한다면** 그것을 커널 어휘로
|
|
76
|
+
* 옮기는 것은 지어내기가 아니다 — 그때 현장의 선언된 시간대로 그 구간의 시작을 적는다.
|
|
77
|
+
*/
|
|
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';
|
|
68
89
|
at?: string;
|
|
69
90
|
}
|
|
70
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)) {
|
|
@@ -233,11 +262,49 @@ export function ingestEnergyGenerationRecords(records, opts) {
|
|
|
233
262
|
const eventTime = String(r?.at ?? '').trim() || opts.defaultEventTime || '';
|
|
234
263
|
if (!eventTime)
|
|
235
264
|
errors.push('at 이 없고 기본 시각도 주지 않았다 — 언제 잰 것인지 지어낼 수 없다');
|
|
265
|
+
/*
|
|
266
|
+
* 기준점은 **말했을 때만** 받는다. 형식이 틀리면 거부한다 — 못 읽는 기준점을 통과시키면 커널이
|
|
267
|
+
* 그것을 「기준점이 없다」로 읽고 다시 추론으로 떨어지는데, 커넥터는 알렸다고 믿는다.
|
|
268
|
+
*/
|
|
269
|
+
let since;
|
|
270
|
+
const rawSince = r?.kWhSince;
|
|
271
|
+
if (rawSince !== undefined && rawSince !== null && String(rawSince).trim()) {
|
|
272
|
+
const text = String(rawSince).trim();
|
|
273
|
+
const at = Date.parse(text);
|
|
274
|
+
if (!Number.isFinite(at))
|
|
275
|
+
errors.push(`kWhSince 를 시각으로 읽을 수 없다: ${JSON.stringify(rawSince)}`);
|
|
276
|
+
else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
|
|
277
|
+
/* 미래에서 쌓이기 시작한 적산은 없다 — 두 값 중 하나가 틀렸고, 어느 쪽인지 우리가 고를 수 없다. */
|
|
278
|
+
errors.push(`kWhSince(${text}) 가 잰 시각(${eventTime})보다 뒤다 — 둘 중 하나가 틀렸다`);
|
|
279
|
+
}
|
|
280
|
+
else
|
|
281
|
+
since = text;
|
|
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
|
+
}
|
|
236
297
|
if (errors.length) {
|
|
237
298
|
rejected.push({ record: r, errors });
|
|
238
299
|
continue;
|
|
239
300
|
}
|
|
240
|
-
const data = {
|
|
301
|
+
const data = {
|
|
302
|
+
equipmentId,
|
|
303
|
+
kWh,
|
|
304
|
+
at: eventTime,
|
|
305
|
+
...(since ? { since } : {}),
|
|
306
|
+
...(accumulation ? { accumulation } : {})
|
|
307
|
+
};
|
|
241
308
|
accepted.push({
|
|
242
309
|
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
243
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"],
|
|
2731
|
+
stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt", "generatedKWhSince", "generatedKWhBasis", "generatedKWhAccumulation", "generatedKWhLastPeriod", "generatedKWhLastPeriodEnd", "generatedKWhLastPeriodObservedFrom", "generatedKWhLastPeriodObservedTo"],
|
|
2714
2732
|
models: ["GeneratingState"],
|
|
2715
2733
|
results: ["generated"]
|
|
2716
2734
|
},
|
|
@@ -7927,17 +7945,119 @@ var EmsKernel = class extends FlowEngine {
|
|
|
7927
7945
|
if (heardAt !== void 0 && atMs < heardAt) return;
|
|
7928
7946
|
this.generationHeardAtMs.set(id, atMs);
|
|
7929
7947
|
const before = Number(eq.generatedKWh);
|
|
7930
|
-
|
|
7948
|
+
const priorSince = eq.generatedKWhSince;
|
|
7949
|
+
const since = typeof d?.since === "string" && d.since.trim() ? d.since.trim() : void 0;
|
|
7950
|
+
const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
|
|
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)) {
|
|
7931
7960
|
;
|
|
7932
7961
|
eq.generatedKWhResetAt = new Date(atMs).toISOString();
|
|
7962
|
+
eq.generatedKWhBasis = sameSince ? "declared" : "inferred";
|
|
7933
7963
|
}
|
|
7934
|
-
;
|
|
7964
|
+
if (since) eq.generatedKWhSince = since;
|
|
7965
|
+
if (accumulation) eq.generatedKWhAccumulation = accumulation;
|
|
7966
|
+
else if (eq.generatedKWhAccumulation === void 0) eq.generatedKWhAccumulation = "unknown";
|
|
7935
7967
|
eq.generatedKWh = kWh;
|
|
7936
7968
|
eq.measuredAt = new Date(atMs).toISOString();
|
|
7969
|
+
this.closeGenerationPeriod(id, eq, { atMs, kWh, dayIndex, kind, since, priorSince, periodAdvanced, faulted: dropped && sameDayDrop });
|
|
7937
7970
|
this.revision++;
|
|
7938
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
|
+
}
|
|
7939
8049
|
/** 설비마다 발전 적산을 마지막으로 들은 시각 — 늦게 온 옛 표본을 가리는 데만 쓴다. */
|
|
7940
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;
|
|
7941
8061
|
applyEquipmentEnergy(envelope) {
|
|
7942
8062
|
const d = envelope.data;
|
|
7943
8063
|
const id = String(d?.equipmentId ?? "").trim();
|
|
@@ -8479,6 +8599,18 @@ var EmsKernel = class extends FlowEngine {
|
|
|
8479
8599
|
if (e.closed?.length) this.closed = e.closed.map((w) => ({ ...w })).slice(-KEEP_CLOSED);
|
|
8480
8600
|
if (Number.isFinite(e.closedTotal)) this.closedTotal = Number(e.closedTotal);
|
|
8481
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);
|
|
8482
8614
|
}
|
|
8483
8615
|
getSnapshot() {
|
|
8484
8616
|
const snap = super.getSnapshot();
|
|
@@ -8495,6 +8627,10 @@ var EmsKernel = class extends FlowEngine {
|
|
|
8495
8627
|
closedTotal: this.closedTotal,
|
|
8496
8628
|
...this.peak ? { peakSince: { ...this.peak } } : {},
|
|
8497
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 } : {},
|
|
8498
8634
|
...contractKW !== void 0 ? { contractKW } : {},
|
|
8499
8635
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
8500
8636
|
...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
|
|
@@ -9348,7 +9484,8 @@ var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"
|
|
|
9348
9484
|
function isEnergyEquipmentRecord(record) {
|
|
9349
9485
|
if (!record || typeof record !== "object") return false;
|
|
9350
9486
|
const r = record;
|
|
9351
|
-
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;
|
|
9352
9489
|
}
|
|
9353
9490
|
function ingestEnergyEquipmentRecords(records, opts) {
|
|
9354
9491
|
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
@@ -9442,6 +9579,13 @@ function ingestEnergyGenerationRecords(records, opts) {
|
|
|
9442
9579
|
const errors = [];
|
|
9443
9580
|
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
9444
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
|
+
}
|
|
9445
9589
|
const raw = r?.kWh;
|
|
9446
9590
|
const kWh = Number(raw);
|
|
9447
9591
|
if (raw === void 0 || raw === null || !Number.isFinite(kWh)) {
|
|
@@ -9451,11 +9595,36 @@ function ingestEnergyGenerationRecords(records, opts) {
|
|
|
9451
9595
|
}
|
|
9452
9596
|
const eventTime = String(r?.at ?? "").trim() || opts.defaultEventTime || "";
|
|
9453
9597
|
if (!eventTime) errors.push("at \uC774 \uC5C6\uACE0 \uAE30\uBCF8 \uC2DC\uAC01\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uC5B8\uC81C \uC7B0 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
|
|
9598
|
+
let since;
|
|
9599
|
+
const rawSince = r?.kWhSince;
|
|
9600
|
+
if (rawSince !== void 0 && rawSince !== null && String(rawSince).trim()) {
|
|
9601
|
+
const text = String(rawSince).trim();
|
|
9602
|
+
const at = Date.parse(text);
|
|
9603
|
+
if (!Number.isFinite(at)) errors.push(`kWhSince \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(rawSince)}`);
|
|
9604
|
+
else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
|
|
9605
|
+
errors.push(`kWhSince(${text}) \uAC00 \uC7B0 \uC2DC\uAC01(${eventTime})\uBCF4\uB2E4 \uB4A4\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4`);
|
|
9606
|
+
} else since = text;
|
|
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
|
+
}
|
|
9454
9617
|
if (errors.length) {
|
|
9455
9618
|
rejected.push({ record: r, errors });
|
|
9456
9619
|
continue;
|
|
9457
9620
|
}
|
|
9458
|
-
const data = {
|
|
9621
|
+
const data = {
|
|
9622
|
+
equipmentId,
|
|
9623
|
+
kWh,
|
|
9624
|
+
at: eventTime,
|
|
9625
|
+
...since ? { since } : {},
|
|
9626
|
+
...accumulation ? { accumulation } : {}
|
|
9627
|
+
};
|
|
9459
9628
|
accepted.push({
|
|
9460
9629
|
eventId: `${opts.tenantId}-generated-${++seq}`,
|
|
9461
9630
|
eventType: ENERGY_EVENT.generated,
|
|
@@ -10162,6 +10331,8 @@ function retiredVocabularyIn(line) {
|
|
|
10162
10331
|
judgeAgainstSpec,
|
|
10163
10332
|
levelOfLocationType,
|
|
10164
10333
|
lgtinClass,
|
|
10334
|
+
localDayIndexAt,
|
|
10335
|
+
localDayStartMs,
|
|
10165
10336
|
locationStatusOf,
|
|
10166
10337
|
lotFromAttributes,
|
|
10167
10338
|
mapRecord,
|
package/package.json
CHANGED