@things-factory/headless-twin 10.0.17 → 10.0.18
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-server/engine/canonical-ingest.d.ts +12 -85
- package/dist-server/engine/canonical-ingest.js +92 -26
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/energy-topology.js +3 -3
- package/dist-server/engine/energy-topology.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +91 -3
- package/dist-server/engine/kpi-fold.js +149 -25
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +99 -11
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/local-declarations.js +6 -5
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/model-vocabulary.js +2 -2
- package/dist-server/engine/model-vocabulary.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +2 -1
- package/dist-server/engine/oee-accumulator.js +3 -2
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -2
- package/dist-server/engine/twin-engine.js +150 -4
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/routes.js +1 -1
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +1 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +11 -2
- package/dist-server/service/reference/reference-master.js +1 -1
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +2 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
- package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
- package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
- package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
- package/dist-server/service/twin-backfill/index.d.ts +2 -0
- package/dist-server/service/twin-backfill/index.js +6 -0
- package/dist-server/service/twin-backfill/index.js.map +1 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/name-index.js +1 -1
- package/dist-server/service/twin-model/name-index.js.map +1 -1
- package/dist-server/service/twin-model/project-structure.js +4 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +2 -2
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +4 -4
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -6
- package/server/engine/canonical-ingest.ts +72 -84
- package/server/engine/energy-topology.ts +1 -1
- package/server/engine/kpi-fold.ts +219 -36
- package/server/engine/kpi-query.ts +97 -4
- package/server/engine/local-declarations.ts +2 -1
- package/server/engine/model-vocabulary.ts +1 -1
- package/server/engine/oee-accumulator.ts +4 -2
- package/server/engine/twin-engine.ts +163 -5
- package/server/routes.ts +6 -1
- package/server/service/index.ts +3 -0
- package/server/service/reference/reference-live.ts +8 -2
- package/server/service/reference/reference-master.ts +12 -3
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
- package/server/service/twin-backfill/backfill-shape.ts +86 -0
- package/server/service/twin-backfill/index.ts +3 -0
- package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
- package/server/service/twin-model/name-index.ts +1 -1
- package/server/service/twin-model/project-structure.ts +3 -2
- package/server/service/twin-model/twin-equipment.ts +14 -2
- package/server/service/twin-model/twin-model-item-query.ts +1 -1
- package/server/service/twin-model/twin-model-query.ts +2 -2
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +1 -1
- package/shared/entity-delta.ts +1 -1
- package/test/axis-read.test.ts +4 -3
- package/test/backfill-shape.test.ts +89 -0
- package/test/broadcast-cost-baseline.test.ts +2 -1
- package/test/canonical-ingest-vocabularies.test.ts +82 -3
- package/test/capability-mapping.test.ts +1 -1
- package/test/contract-layer-guard.test.ts +48 -0
- package/test/declaration-reaches-model.test.ts +2 -0
- package/test/equipment-identity-reaches.test.ts +57 -0
- package/test/fact-scope-wiring.test.ts +96 -0
- package/test/generated-not-counted.test.ts +99 -0
- package/test/ingest-bench.test.ts +2 -1
- package/test/ingest-wiring-guard.test.ts +197 -0
- package/test/kpi-fold.test.ts +81 -0
- package/test/live-mirror-parity.test.ts +6 -3
- package/test/local-declarations.test.ts +3 -2
- package/test/master-to-twin.test.ts +2 -2
- package/test/oee-accumulator.test.ts +18 -3
- package/test/projection-reaches-screen.test.ts +5 -0
- package/test/projection-reads-declared.test.ts +109 -0
- package/test/property-effects.test.ts +7 -3
- package/test/scale-twin-bench.test.ts +2 -1
- package/test/vocabulary-guard.test.ts +1 -1
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ElectricityCost, type TariffDeclaration
|
|
1
|
+
import { type ElectricityCost, type TariffDeclaration } from '@operato/twin-kernel';
|
|
2
|
+
import { type WorkCalendarEntry } from '@operato/ops-contract';
|
|
2
3
|
/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
|
|
3
4
|
export interface KpiEvent {
|
|
4
5
|
eventType?: string;
|
|
@@ -124,6 +125,51 @@ export interface KpiResult {
|
|
|
124
125
|
id: string;
|
|
125
126
|
kWh: number;
|
|
126
127
|
}[];
|
|
128
|
+
/**
|
|
129
|
+
* **이 창에 다 들어오지 않아 더하지 못한 기간** — 조용히 버리지 않는다.
|
|
130
|
+
*
|
|
131
|
+
* 더하는 것은 창에 온전히 든 기간뿐이다(걸친 기간을 더하면 다른 날 값이 이 창의 값인 척한다).
|
|
132
|
+
* 그런데 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 드는 일이 거의 없다 — 창이 한낮에서
|
|
133
|
+
* 시작하기 때문이다. 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고, 사용자는 그것을
|
|
134
|
+
* 「발전량이 없다」로 읽었다(2026-08-30 실측).
|
|
135
|
+
*
|
|
136
|
+
* 이 값이 있으면 화면은 **「이 구간에 다 들어오지 않은 기간이 있다」**를 말해야 한다.
|
|
137
|
+
*/
|
|
138
|
+
notCounted?: {
|
|
139
|
+
periods: number;
|
|
140
|
+
kWh: number;
|
|
141
|
+
earliestStart: string;
|
|
142
|
+
latestEnd: string;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* 이용률 — 만든 양 ÷ (정격 × 그 기간의 시간).
|
|
146
|
+
*
|
|
147
|
+
* **어느 정격으로 나눴는지를 함께 낸다.** 변환기를 낀 발전은 직류 정격이 교류보다 크고,
|
|
148
|
+
* 직류로 나눠 보여 주는 시스템이 실제로 있다. 밝히지 않으면 두 화면의 수가 두 배 달라도
|
|
149
|
+
* 왜 다른지 말할 수 없다.
|
|
150
|
+
*
|
|
151
|
+
* 창 전체가 아니라 **센 기간들의 길이**로 나눈다 — 하루치 하나만 센 24시간 창에서 창 길이로
|
|
152
|
+
* 나누면 그 하루가 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
153
|
+
*/
|
|
154
|
+
capacityFactor?: {
|
|
155
|
+
value: number;
|
|
156
|
+
ratedKW: number;
|
|
157
|
+
basis: 'ac' | 'dc';
|
|
158
|
+
from: 'equipment' | 'location';
|
|
159
|
+
hours: number;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* 낸 것의 값 — 기간마다 그때의 단가로 매긴다.
|
|
163
|
+
*
|
|
164
|
+
* **모든 기간에 단가가 있을 때만** 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인
|
|
165
|
+
* 척한다 — 못 매긴 기간 수를 함께 낸다.
|
|
166
|
+
*/
|
|
167
|
+
revenue?: {
|
|
168
|
+
amount: number;
|
|
169
|
+
currency: string;
|
|
170
|
+
priced: number;
|
|
171
|
+
unpriced: number;
|
|
172
|
+
};
|
|
127
173
|
};
|
|
128
174
|
/**
|
|
129
175
|
* `kWh` 가 어느 회계에서 왔나.
|
|
@@ -148,9 +194,22 @@ export interface KpiResult {
|
|
|
148
194
|
*/
|
|
149
195
|
billingDemand?: {
|
|
150
196
|
kW: number;
|
|
151
|
-
from: 'bill' | 'carried-forward' | 'declaration';
|
|
197
|
+
from: 'tariff-basis' | 'bill' | 'carried-forward' | 'declaration';
|
|
152
198
|
declaredKW?: number;
|
|
153
199
|
stale?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* 이 창에서 관측한 최고 수요가 그 기준을 넘었다 — **다음 주기의 기본요금이 오른다.**
|
|
202
|
+
*
|
|
203
|
+
* 계약 초과와 다른 값이다. 계약 초과는 지금 줄이면 되고, 이것은 이미 정해진 결과다.
|
|
204
|
+
*
|
|
205
|
+
* `observedTo` 는 이 최고값이 언제까지의 것인지다. 최고 수요는 구간이 마감되어야 오므로
|
|
206
|
+
* 넘은 것을 뒤늦게 안다 — 그 한계를 화면이 말할 수 있어야 한다.
|
|
207
|
+
*/
|
|
208
|
+
exceeded?: {
|
|
209
|
+
peakKW: number;
|
|
210
|
+
overKW: number;
|
|
211
|
+
observedTo: string;
|
|
212
|
+
};
|
|
154
213
|
};
|
|
155
214
|
/**
|
|
156
215
|
* 밖에서 마감되어 온 사용 구간 — 계량 표본이 없는 현장의 유일한 근거다.
|
|
@@ -348,7 +407,6 @@ export interface KpiResult {
|
|
|
348
407
|
truncatedTasks?: number;
|
|
349
408
|
};
|
|
350
409
|
}
|
|
351
|
-
/** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */
|
|
352
410
|
export interface KpiFoldOptions {
|
|
353
411
|
groupBy?: KpiGroupBy;
|
|
354
412
|
/**
|
|
@@ -361,6 +419,36 @@ export interface KpiFoldOptions {
|
|
|
361
419
|
to: string;
|
|
362
420
|
billingDemandKW?: number;
|
|
363
421
|
};
|
|
422
|
+
/**
|
|
423
|
+
* 이 트윈이 이어받고 있는 **요금 기준** — 이 주기에 적용되는 값(§`ENERGY_EVENT.tariffBasis`).
|
|
424
|
+
*
|
|
425
|
+
* 청구서보다 먼저다. 청구서는 끝난 기간의 정산이라 이번 주기의 기준을 말하지 않는다.
|
|
426
|
+
*/
|
|
427
|
+
tariffBasis?: {
|
|
428
|
+
from: string;
|
|
429
|
+
billingDemandKW?: number;
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* 이 트윈의 **발전 정격**과 그 근거 — 이용률의 분모(§ 커널 `generationRated`).
|
|
433
|
+
*
|
|
434
|
+
* 모델의 선언에서 오므로 호출부가 읽어 넘긴다. 폴드는 저널만 본다.
|
|
435
|
+
*/
|
|
436
|
+
generationRated?: {
|
|
437
|
+
kW: number;
|
|
438
|
+
basis: 'ac' | 'dc';
|
|
439
|
+
from: 'equipment' | 'location';
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* 이 트윈이 이어받고 있는 **발전 단가** — 창 밖의 단가도 이 창에 적용될 수 있다.
|
|
443
|
+
*
|
|
444
|
+
* 창 안의 단가 사실이 먼저다. 이것은 그 창에 단가가 없을 때 쓴다.
|
|
445
|
+
*/
|
|
446
|
+
generationPrice?: {
|
|
447
|
+
from: string;
|
|
448
|
+
to: string;
|
|
449
|
+
unitPrice: number;
|
|
450
|
+
currency: string;
|
|
451
|
+
};
|
|
364
452
|
/**
|
|
365
453
|
* 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
|
|
366
454
|
* 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(알리지 않고 다른 축으로 바꾸지 않는다).
|
|
@@ -32,6 +32,7 @@ exports.foldKpiBuckets = foldKpiBuckets;
|
|
|
32
32
|
* 이 파일은 이제 "계산한 기록 → 창 지표" 만 한다.
|
|
33
33
|
*/
|
|
34
34
|
const twin_kernel_1 = require("@operato/twin-kernel");
|
|
35
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
35
36
|
/**
|
|
36
37
|
* 창의 전력량을 **배전 계통의 자리별로 나눈다** — 근거를 함께.
|
|
37
38
|
*
|
|
@@ -144,10 +145,37 @@ function feederShares(kWh, utilization, resourceFeeder) {
|
|
|
144
145
|
* 기간의 끝이 자정인데 마지막 표본이 저녁이었으면 그 사이는 재지 않은 것이다. 그 수를 함께 내야 화면이
|
|
145
146
|
* 「이 합은 이만큼 덜 잰 것」이라고 말할 수 있다. 얼마나 어긋나면 문제인지는 현장이 정한다.
|
|
146
147
|
*/
|
|
147
|
-
function generatedOfPeriods(periods, range
|
|
148
|
+
function generatedOfPeriods(periods, range,
|
|
149
|
+
/** 이용률의 분모 — 없으면 이용률을 내지 않는다(0 이나 짐작을 만들지 않는다). */
|
|
150
|
+
rated,
|
|
151
|
+
/** 기간마다 적용할 단가 — 기간을 덮는 것을 쓴다. 없으면 금액을 만들지 않는다. */
|
|
152
|
+
prices) {
|
|
148
153
|
const inside = periods.filter(g => g.startMs >= range.startMs && g.endMs <= range.endMs);
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
/*
|
|
155
|
+
* ── **걸친 기간을 조용히 버리지 않는다** (2026-08-30) ──────────────────────
|
|
156
|
+
*
|
|
157
|
+
* 더하는 것은 창에 **온전히 들어온 기간**뿐이다. 그것이 맞는 셈이다 — 걸친 기간을 더하면 다른 날의
|
|
158
|
+
* 값이 이 창의 값인 척한다.
|
|
159
|
+
*
|
|
160
|
+
* 그런데 버린 것을 말하지 않았다. 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 들어오는 일이
|
|
161
|
+
* 거의 없다(창이 한낮에서 시작하므로). 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고,
|
|
162
|
+
* 사용자는 그것을 **「발전량이 없다」**로 읽었다. 실측으로 그 일이 났다.
|
|
163
|
+
*
|
|
164
|
+
* 이제 걸친 것을 세어 함께 낸다. 화면이 「이 창에 다 들어오지 않은 기간이 있다」를 말할 수 있다.
|
|
165
|
+
*/
|
|
166
|
+
const straddling = periods.filter(g => !(g.startMs >= range.startMs && g.endMs <= range.endMs) && g.endMs > range.startMs && g.startMs < range.endMs);
|
|
167
|
+
const notCounted = straddling.length
|
|
168
|
+
? {
|
|
169
|
+
periods: straddling.length,
|
|
170
|
+
kWh: straddling.reduce((a, g) => a + g.kWh, 0),
|
|
171
|
+
earliestStart: new Date(Math.min(...straddling.map(g => g.startMs))).toISOString(),
|
|
172
|
+
latestEnd: new Date(Math.max(...straddling.map(g => g.endMs))).toISOString()
|
|
173
|
+
}
|
|
174
|
+
: undefined;
|
|
175
|
+
if (!inside.length) {
|
|
176
|
+
/* 온전히 들어온 것이 없다 — 그래도 걸친 것이 있으면 그 사실을 낸다(빈 값과 「없음」은 다르다). */
|
|
177
|
+
return notCounted ? { generated: { kWh: 0, counted: 0, partial: 0, byEquipment: [], notCounted } } : {};
|
|
178
|
+
}
|
|
151
179
|
const byEquipment = new Map();
|
|
152
180
|
let partial = 0;
|
|
153
181
|
for (const g of inside) {
|
|
@@ -157,14 +185,46 @@ function generatedOfPeriods(periods, range) {
|
|
|
157
185
|
if (!coveredFrom || !coveredTo)
|
|
158
186
|
partial++;
|
|
159
187
|
}
|
|
188
|
+
const kWh = inside.reduce((a, g) => a + g.kWh, 0);
|
|
189
|
+
/*
|
|
190
|
+
* 이용률의 분모는 **센 기간들의 길이**다. 창 길이로 나누면, 하루치 하나만 센 24시간 창에서 그 하루가
|
|
191
|
+
* 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
192
|
+
*/
|
|
193
|
+
const hours = inside.reduce((a, g) => a + (g.endMs - g.startMs) / 3_600_000, 0);
|
|
194
|
+
const capacityFactor = rated && rated.kW > 0 && hours > 0
|
|
195
|
+
? { value: kWh / (rated.kW * hours), ratedKW: rated.kW, basis: rated.basis, from: rated.from, hours }
|
|
196
|
+
: undefined;
|
|
197
|
+
/*
|
|
198
|
+
* 낸 것의 값 — 기간마다 **그 기간을 덮는 단가**로 매긴다. 걸친 단가는 쓰지 않는다: 하루 단가로
|
|
199
|
+
* 이틀치를 매기면 그 절반은 다른 날의 값이 된다.
|
|
200
|
+
*
|
|
201
|
+
* 모든 기간에 단가가 있을 때만 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인 척한다.
|
|
202
|
+
*/
|
|
203
|
+
let amount = 0;
|
|
204
|
+
let priced = 0;
|
|
205
|
+
let currency;
|
|
206
|
+
for (const g of inside) {
|
|
207
|
+
const p = (prices ?? []).find(x => x.startMs <= g.startMs && x.endMs >= g.endMs);
|
|
208
|
+
if (!p)
|
|
209
|
+
continue;
|
|
210
|
+
amount += g.kWh * p.unitPrice;
|
|
211
|
+
priced++;
|
|
212
|
+
currency = currency ?? p.currency;
|
|
213
|
+
}
|
|
214
|
+
const unpriced = inside.length - priced;
|
|
215
|
+
const revenue = priced > 0 && unpriced === 0 && currency ? { amount, currency, priced, unpriced } : undefined;
|
|
160
216
|
return {
|
|
161
217
|
generated: {
|
|
162
|
-
kWh
|
|
218
|
+
kWh,
|
|
163
219
|
counted: inside.length,
|
|
164
220
|
partial,
|
|
165
221
|
byEquipment: [...byEquipment.entries()]
|
|
166
222
|
.map(([id, kWh]) => ({ id, kWh }))
|
|
167
|
-
.sort((a, b) => b.kWh - a.kWh)
|
|
223
|
+
.sort((a, b) => b.kWh - a.kWh),
|
|
224
|
+
...(capacityFactor ? { capacityFactor } : {}),
|
|
225
|
+
...(revenue ? { revenue } : {}),
|
|
226
|
+
/* 이 창에 다 들어오지 않아 더하지 못한 기간 — 있으면 화면이 그것을 말한다. */
|
|
227
|
+
...(notCounted ? { notCounted } : {})
|
|
168
228
|
}
|
|
169
229
|
};
|
|
170
230
|
}
|
|
@@ -211,26 +271,40 @@ function usageOfPeriods(periods, range) {
|
|
|
211
271
|
* 순서에 뜻이 있다. 청구서는 그 기간에 실제로 매겨진 값이고, 선언은 사람이 옮겨 적은 값이다.
|
|
212
272
|
* 그 값이 매달 바뀌므로 옮겨 적은 것은 곧 낡는다.
|
|
213
273
|
*/
|
|
214
|
-
function billingDemandOf(billsInWindow, lastBill, declaredKW
|
|
274
|
+
function billingDemandOf(billsInWindow, tariffBasis, lastBill, declaredKW,
|
|
275
|
+
/** 이 창에서 관측한 최고 수요와 그 값이 언제까지의 것인지 — 사용 구간의 `maxKW` 에서 온다. */
|
|
276
|
+
observedPeak) {
|
|
215
277
|
const declared = Number.isFinite(Number(declaredKW)) ? Number(declaredKW) : undefined;
|
|
216
278
|
const withValue = billsInWindow.filter(b => Number.isFinite(b.billingDemandKW));
|
|
217
279
|
const fromBill = withValue.length ? withValue.reduce((a, x) => (x.endMs > a.endMs ? x : a)).billingDemandKW : undefined;
|
|
218
280
|
const carried = Number.isFinite(Number(lastBill?.billingDemandKW)) ? Number(lastBill?.billingDemandKW) : undefined;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
281
|
+
/* 이 주기에 적용되는 기준이 가장 먼저다 — 청구서는 끝난 기간의 것이다. */
|
|
282
|
+
const fromBasis = Number.isFinite(Number(tariffBasis?.billingDemandKW)) ? Number(tariffBasis?.billingDemandKW) : undefined;
|
|
283
|
+
const picked = fromBasis !== undefined
|
|
284
|
+
? { kW: fromBasis, from: 'tariff-basis' }
|
|
285
|
+
: fromBill !== undefined
|
|
286
|
+
? { kW: fromBill, from: 'bill' }
|
|
287
|
+
: carried !== undefined
|
|
288
|
+
? { kW: carried, from: 'carried-forward' }
|
|
289
|
+
: declared !== undefined
|
|
290
|
+
? { kW: declared, from: 'declaration' }
|
|
291
|
+
: undefined;
|
|
226
292
|
if (!picked)
|
|
227
293
|
return undefined;
|
|
228
294
|
/* 선언이 있는데 청구서와 다르면 그 사실을 낸다 — 낡은 선언은 스스로 말해야 한다. */
|
|
229
295
|
const stale = picked.from !== 'declaration' && declared !== undefined && declared !== picked.kW;
|
|
296
|
+
const exceeded = observedPeak && observedPeak.kW > picked.kW
|
|
297
|
+
? {
|
|
298
|
+
peakKW: observedPeak.kW,
|
|
299
|
+
overKW: observedPeak.kW - picked.kW,
|
|
300
|
+
observedTo: new Date(observedPeak.toMs).toISOString()
|
|
301
|
+
}
|
|
302
|
+
: undefined;
|
|
230
303
|
return {
|
|
231
304
|
...picked,
|
|
232
305
|
...(declared !== undefined ? { declaredKW: declared } : {}),
|
|
233
|
-
...(stale ? { stale: true } : {})
|
|
306
|
+
...(stale ? { stale: true } : {}),
|
|
307
|
+
...(exceeded ? { exceeded } : {})
|
|
234
308
|
};
|
|
235
309
|
}
|
|
236
310
|
/**
|
|
@@ -339,6 +413,28 @@ function measuredFeeders(windows, range, totalKWh, meterFeeder) {
|
|
|
339
413
|
...(remainder < 0 ? { submeterExcessKWh: -remainder } : {})
|
|
340
414
|
};
|
|
341
415
|
}
|
|
416
|
+
/** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */
|
|
417
|
+
/*
|
|
418
|
+
* ── 같은 사실이 두 벌 있어도 한 번만 센다 (2026-08-30) ──────────────────────
|
|
419
|
+
*
|
|
420
|
+
* 마감된 구간 사실은 정체성이 내용에 있다(커널 0.7.81). 그런데 저널에 이미 여러 벌 적힌 것이 있다 —
|
|
421
|
+
* 쓰는 자리에서 막기 전에 들어간 것들이다(실측: 어느 날의 발전 단가가 세 벌).
|
|
422
|
+
*
|
|
423
|
+
* 지난 기록을 지우지 않는다 — 저널은 일어난 일의 기록이다. 대신 **읽을 때 한 번만 센다.**
|
|
424
|
+
* 그러지 않으면 그 구간의 수익·사용량이 그 횟수만큼 세어진다.
|
|
425
|
+
*/
|
|
426
|
+
function onceById(rows) {
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
return rows.filter(r => {
|
|
429
|
+
const id = r?.payload?.eventId;
|
|
430
|
+
if (typeof id !== 'string' || !id)
|
|
431
|
+
return true;
|
|
432
|
+
if (seen.has(id))
|
|
433
|
+
return false;
|
|
434
|
+
seen.add(id);
|
|
435
|
+
return true;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
342
438
|
/** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(알리지 않고 버리지 않는다). */
|
|
343
439
|
function facetKey(rec, options) {
|
|
344
440
|
const f = rec.facets;
|
|
@@ -370,7 +466,7 @@ function facetKey(rec, options) {
|
|
|
370
466
|
return 'unknown';
|
|
371
467
|
/* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도
|
|
372
468
|
않은 교대에 집계돼, 교대별 성과가 알리지 않고 거짓이 된다. */
|
|
373
|
-
return (0,
|
|
469
|
+
return (0, ops_contract_1.activeShiftAt)(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown';
|
|
374
470
|
default:
|
|
375
471
|
return 'unknown';
|
|
376
472
|
}
|
|
@@ -507,7 +603,7 @@ function foldKpi(events, window, options = {}) {
|
|
|
507
603
|
* 판정의 1차 근거는 **양**이고 낱말은 양으로 말할 수 없는 종결(취소·중도 종료)에만 쓴다.
|
|
508
604
|
*/
|
|
509
605
|
const status = String(d.status ?? '').toLowerCase();
|
|
510
|
-
const done = (0,
|
|
606
|
+
const done = (0, ops_contract_1.isOrderTerminal)({ status: d.status, requested: d.requested, fulfilled: d.fulfilled });
|
|
511
607
|
if (at >= window.fromMs && at <= window.toMs) {
|
|
512
608
|
/*
|
|
513
609
|
* **오더당 한 번만 센다.** 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수
|
|
@@ -540,7 +636,7 @@ function foldKpi(events, window, options = {}) {
|
|
|
540
636
|
* 사건이 하나도 없으면 이 트윈에는 에너지가 없는 것이므로 블록 자체를 내지 않는다.
|
|
541
637
|
*/
|
|
542
638
|
const demandWindows = events
|
|
543
|
-
.filter(e => e.eventType ===
|
|
639
|
+
.filter(e => e.eventType === ops_contract_1.ENERGY_EVENT.demandWindow)
|
|
544
640
|
.map(e => {
|
|
545
641
|
const d = e.payload?.data ?? e.payload ?? {};
|
|
546
642
|
/* 최대(`maxKW`)도 함께 넘긴다 — 요금이 걸리는 수는 평균이 아니라 최대다.
|
|
@@ -569,8 +665,7 @@ function foldKpi(events, window, options = {}) {
|
|
|
569
665
|
*
|
|
570
666
|
* 관측 구간(`observedFrom`·`observedTo`)을 함께 읽는다 — 값의 근거가 얼마나 덮였는지가 그 둘에 있다.
|
|
571
667
|
*/
|
|
572
|
-
const generationPeriods = events
|
|
573
|
-
.filter(e => e.eventType === twin_kernel_1.ENERGY_EVENT.generationPeriod)
|
|
668
|
+
const generationPeriods = onceById(events.filter(e => e.eventType === ops_contract_1.ENERGY_EVENT.generationPeriod))
|
|
574
669
|
.map(e => {
|
|
575
670
|
const d = e.payload?.data ?? e.payload ?? {};
|
|
576
671
|
return {
|
|
@@ -586,8 +681,7 @@ function foldKpi(events, window, options = {}) {
|
|
|
586
681
|
/*
|
|
587
682
|
* 밖에서 마감되어 온 사용 구간 — 구간 마감과 같은 규칙으로 **창에 온전히 든 것만** 센다.
|
|
588
683
|
*/
|
|
589
|
-
const usagePeriods = events
|
|
590
|
-
.filter(e => e.eventType === twin_kernel_1.ENERGY_EVENT.usagePeriod)
|
|
684
|
+
const usagePeriods = onceById(events.filter(e => e.eventType === ops_contract_1.ENERGY_EVENT.usagePeriod))
|
|
591
685
|
.map(e => {
|
|
592
686
|
const d = e.payload?.data ?? e.payload ?? {};
|
|
593
687
|
return {
|
|
@@ -596,14 +690,14 @@ function foldKpi(events, window, options = {}) {
|
|
|
596
690
|
startMs: Date.parse(String(d.from ?? '')),
|
|
597
691
|
endMs: Date.parse(String(d.to ?? '')),
|
|
598
692
|
unitPrice: Number(d.unitPrice),
|
|
693
|
+
maxKW: Number(d.maxKW),
|
|
599
694
|
currency: d.currency ? String(d.currency) : undefined,
|
|
600
695
|
basis: d.basis ? String(d.basis) : 'unstated'
|
|
601
696
|
};
|
|
602
697
|
})
|
|
603
698
|
.filter(u => u.meterId && Number.isFinite(u.kWh) && Number.isFinite(u.startMs) && Number.isFinite(u.endMs));
|
|
604
699
|
/* 청구서 — 창에 온전히 든 것 중 가장 늦게 끝난 것 하나. */
|
|
605
|
-
const bills = events
|
|
606
|
-
.filter(e => e.eventType === twin_kernel_1.ENERGY_EVENT.bill)
|
|
700
|
+
const bills = onceById(events.filter(e => e.eventType === ops_contract_1.ENERGY_EVENT.bill))
|
|
607
701
|
.map(e => {
|
|
608
702
|
const d = e.payload?.data ?? e.payload ?? {};
|
|
609
703
|
return {
|
|
@@ -619,6 +713,18 @@ function foldKpi(events, window, options = {}) {
|
|
|
619
713
|
};
|
|
620
714
|
})
|
|
621
715
|
.filter(b => Number.isFinite(b.startMs) && Number.isFinite(b.endMs));
|
|
716
|
+
/* 발전 단가 — 기간마다 그때의 값이다(날마다 바뀐다). */
|
|
717
|
+
const generationPrices = onceById(events.filter(e => e.eventType === ops_contract_1.ENERGY_EVENT.generationPrice))
|
|
718
|
+
.map(e => {
|
|
719
|
+
const d = e.payload?.data ?? e.payload ?? {};
|
|
720
|
+
return {
|
|
721
|
+
startMs: Date.parse(String(d.from ?? '')),
|
|
722
|
+
endMs: Date.parse(String(d.to ?? '')),
|
|
723
|
+
unitPrice: Number(d.unitPrice),
|
|
724
|
+
currency: String(d.currency ?? '')
|
|
725
|
+
};
|
|
726
|
+
})
|
|
727
|
+
.filter(p => Number.isFinite(p.startMs) && Number.isFinite(p.endMs) && Number.isFinite(p.unitPrice) && p.currency);
|
|
622
728
|
const leads = [];
|
|
623
729
|
const works = [];
|
|
624
730
|
const waits = [];
|
|
@@ -686,7 +792,12 @@ function foldKpi(events, window, options = {}) {
|
|
|
686
792
|
* 다른 단가가 사라지고, 「피크를 깎아 얼마를 아끼나」가 그 차이 위에 선다.
|
|
687
793
|
*/
|
|
688
794
|
/* 요금적용전력은 청구서가 먼저다 — 선언은 청구가 오지 않는 현장의 몫이다. */
|
|
689
|
-
|
|
795
|
+
/* 이 창의 최고 수요 — 사용 구간이 실어 온 값 중 가장 큰 것(원본 계량기가 잰 값이다). */
|
|
796
|
+
const peaks = usagePeriods
|
|
797
|
+
.filter(u => u.startMs >= window.fromMs && u.endMs <= window.toMs && Number.isFinite(u.maxKW))
|
|
798
|
+
.map(u => ({ kW: u.maxKW, toMs: u.endMs }));
|
|
799
|
+
const observedPeak = peaks.length ? peaks.reduce((a, x) => (x.kW > a.kW ? x : a)) : undefined;
|
|
800
|
+
const billingDemand = billingDemandOf(bills.filter(b => Number.isFinite(b.billingDemandKW)).map(b => ({ endMs: b.endMs, billingDemandKW: b.billingDemandKW })), options.tariffBasis, options.lastBill, options.tariff?.billingDemandKW, observedPeak);
|
|
690
801
|
const cost = options.tariff
|
|
691
802
|
? (0, twin_kernel_1.electricityCost)({
|
|
692
803
|
kWh: usedKWh,
|
|
@@ -717,7 +828,20 @@ function foldKpi(events, window, options = {}) {
|
|
|
717
828
|
feederShares(e.kWh, utilization, options.resourceFeeder)),
|
|
718
829
|
/* 금액은 같은 창의 전력량·최대수요에서 나온다 — 창이 하나뿐이라 어긋날 수 없다. */
|
|
719
830
|
...(cost ? { cost } : {}),
|
|
720
|
-
...generatedOfPeriods(generationPeriods, { startMs: window.fromMs, endMs: window.toMs }
|
|
831
|
+
...generatedOfPeriods(generationPeriods, { startMs: window.fromMs, endMs: window.toMs }, options.generationRated,
|
|
832
|
+
/* 창 안의 단가 사실이 먼저이고, 없으면 이어받은 마지막 단가를 쓴다. */
|
|
833
|
+
generationPrices.length
|
|
834
|
+
? generationPrices
|
|
835
|
+
: options.generationPrice
|
|
836
|
+
? [
|
|
837
|
+
{
|
|
838
|
+
startMs: Date.parse(options.generationPrice.from),
|
|
839
|
+
endMs: Date.parse(options.generationPrice.to),
|
|
840
|
+
unitPrice: options.generationPrice.unitPrice,
|
|
841
|
+
currency: options.generationPrice.currency
|
|
842
|
+
}
|
|
843
|
+
]
|
|
844
|
+
: []),
|
|
721
845
|
/* 청구서는 사실이다 — 우리 계산을 덮지 않고 나란히 둔다(§`billOfWindow`). */
|
|
722
846
|
...(() => {
|
|
723
847
|
const b = billOfWindow(bills, { startMs: window.fromMs, endMs: window.toMs }, cost);
|