@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.
Files changed (122) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +12 -85
  2. package/dist-server/engine/canonical-ingest.js +92 -26
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/energy-topology.js +3 -3
  5. package/dist-server/engine/energy-topology.js.map +1 -1
  6. package/dist-server/engine/kpi-fold.d.ts +91 -3
  7. package/dist-server/engine/kpi-fold.js +149 -25
  8. package/dist-server/engine/kpi-fold.js.map +1 -1
  9. package/dist-server/engine/kpi-query.js +99 -11
  10. package/dist-server/engine/kpi-query.js.map +1 -1
  11. package/dist-server/engine/local-declarations.js +6 -5
  12. package/dist-server/engine/local-declarations.js.map +1 -1
  13. package/dist-server/engine/model-vocabulary.js +2 -2
  14. package/dist-server/engine/model-vocabulary.js.map +1 -1
  15. package/dist-server/engine/oee-accumulator.d.ts +2 -1
  16. package/dist-server/engine/oee-accumulator.js +3 -2
  17. package/dist-server/engine/oee-accumulator.js.map +1 -1
  18. package/dist-server/engine/twin-engine.d.ts +72 -2
  19. package/dist-server/engine/twin-engine.js +150 -4
  20. package/dist-server/engine/twin-engine.js.map +1 -1
  21. package/dist-server/routes.js +1 -1
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/index.d.ts +1 -1
  24. package/dist-server/service/index.js +3 -0
  25. package/dist-server/service/index.js.map +1 -1
  26. package/dist-server/service/reference/reference-live.js +1 -1
  27. package/dist-server/service/reference/reference-live.js.map +1 -1
  28. package/dist-server/service/reference/reference-master.d.ts +11 -2
  29. package/dist-server/service/reference/reference-master.js +1 -1
  30. package/dist-server/service/reference/reference-master.js.map +1 -1
  31. package/dist-server/service/reference/reference-resolver.js +2 -2
  32. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  33. package/dist-server/service/reference/template-registry.d.ts +1 -1
  34. package/dist-server/service/reference/template-registry.js.map +1 -1
  35. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  36. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  37. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  38. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  39. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  40. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  41. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  42. package/dist-server/service/twin-backfill/index.js +6 -0
  43. package/dist-server/service/twin-backfill/index.js.map +1 -0
  44. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  45. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  46. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  47. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  48. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  49. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  50. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  51. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  52. package/dist-server/service/twin-model/name-index.js +1 -1
  53. package/dist-server/service/twin-model/name-index.js.map +1 -1
  54. package/dist-server/service/twin-model/project-structure.js +4 -3
  55. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  56. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  57. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  58. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  59. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  60. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  61. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  62. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  63. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  64. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  65. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  66. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  67. package/dist-shared/entity-delta.js +1 -1
  68. package/dist-shared/entity-delta.js.map +1 -1
  69. package/package.json +7 -6
  70. package/server/engine/canonical-ingest.ts +72 -84
  71. package/server/engine/energy-topology.ts +1 -1
  72. package/server/engine/kpi-fold.ts +219 -36
  73. package/server/engine/kpi-query.ts +97 -4
  74. package/server/engine/local-declarations.ts +2 -1
  75. package/server/engine/model-vocabulary.ts +1 -1
  76. package/server/engine/oee-accumulator.ts +4 -2
  77. package/server/engine/twin-engine.ts +163 -5
  78. package/server/routes.ts +6 -1
  79. package/server/service/index.ts +3 -0
  80. package/server/service/reference/reference-live.ts +8 -2
  81. package/server/service/reference/reference-master.ts +12 -3
  82. package/server/service/reference/reference-resolver.ts +1 -1
  83. package/server/service/reference/template-registry.ts +1 -1
  84. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  85. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  86. package/server/service/twin-backfill/index.ts +3 -0
  87. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  88. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  89. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  90. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  91. package/server/service/twin-model/name-index.ts +1 -1
  92. package/server/service/twin-model/project-structure.ts +3 -2
  93. package/server/service/twin-model/twin-equipment.ts +14 -2
  94. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  95. package/server/service/twin-model/twin-model-query.ts +2 -2
  96. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  97. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  98. package/shared/entity-delta.ts +1 -1
  99. package/test/axis-read.test.ts +4 -3
  100. package/test/backfill-shape.test.ts +89 -0
  101. package/test/broadcast-cost-baseline.test.ts +2 -1
  102. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  103. package/test/capability-mapping.test.ts +1 -1
  104. package/test/contract-layer-guard.test.ts +48 -0
  105. package/test/declaration-reaches-model.test.ts +2 -0
  106. package/test/equipment-identity-reaches.test.ts +57 -0
  107. package/test/fact-scope-wiring.test.ts +96 -0
  108. package/test/generated-not-counted.test.ts +99 -0
  109. package/test/ingest-bench.test.ts +2 -1
  110. package/test/ingest-wiring-guard.test.ts +197 -0
  111. package/test/kpi-fold.test.ts +81 -0
  112. package/test/live-mirror-parity.test.ts +6 -3
  113. package/test/local-declarations.test.ts +3 -2
  114. package/test/master-to-twin.test.ts +2 -2
  115. package/test/oee-accumulator.test.ts +18 -3
  116. package/test/projection-reaches-screen.test.ts +5 -0
  117. package/test/projection-reads-declared.test.ts +109 -0
  118. package/test/property-effects.test.ts +7 -3
  119. package/test/scale-twin-bench.test.ts +2 -1
  120. package/test/vocabulary-guard.test.ts +1 -1
  121. package/tsconfig.shared.tsbuildinfo +1 -1
  122. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,5 @@
1
- import { type ElectricityCost, type TariffDeclaration, type WorkCalendarEntry } from '@operato/twin-kernel';
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
- if (!inside.length)
150
- return {};
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: inside.reduce((a, g) => a + g.kWh, 0),
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
- const picked = fromBill !== undefined
220
- ? { kW: fromBill, from: 'bill' }
221
- : carried !== undefined
222
- ? { kW: carried, from: 'carried-forward' }
223
- : declared !== undefined
224
- ? { kW: declared, from: 'declaration' }
225
- : undefined;
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, twin_kernel_1.activeShiftAt)(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown';
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, twin_kernel_1.isOrderTerminal)({ status: d.status, requested: d.requested, fulfilled: d.fulfilled });
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 === twin_kernel_1.ENERGY_EVENT.demandWindow)
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
- const billingDemand = billingDemandOf(bills.filter(b => Number.isFinite(b.billingDemandKW)).map(b => ({ endMs: b.endMs, billingDemandKW: b.billingDemandKW })), options.lastBill, options.tariff?.billingDemandKW);
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);