@things-factory/headless-twin 10.1.278 → 10.1.280

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 (614) hide show
  1. package/package.json +21 -7
  2. package/dist-server/engine/attention-digest.js.map +0 -1
  3. package/dist-server/engine/canonical-ingest.js.map +0 -1
  4. package/dist-server/engine/checkpoint-fold.js.map +0 -1
  5. package/dist-server/engine/command-routing.js.map +0 -1
  6. package/dist-server/engine/declared-stimulus.js.map +0 -1
  7. package/dist-server/engine/energy-topology.js.map +0 -1
  8. package/dist-server/engine/event-time.js.map +0 -1
  9. package/dist-server/engine/index.js.map +0 -1
  10. package/dist-server/engine/ingest-dedupe.js.map +0 -1
  11. package/dist-server/engine/ingest-health.js.map +0 -1
  12. package/dist-server/engine/integration-coverage.js.map +0 -1
  13. package/dist-server/engine/integration-probes.js.map +0 -1
  14. package/dist-server/engine/integration-runner.js.map +0 -1
  15. package/dist-server/engine/integration-target-profile.js.map +0 -1
  16. package/dist-server/engine/kpi-baseline.js.map +0 -1
  17. package/dist-server/engine/kpi-fold.js.map +0 -1
  18. package/dist-server/engine/kpi-query.js.map +0 -1
  19. package/dist-server/engine/kpi-target.js.map +0 -1
  20. package/dist-server/engine/live-attentions.js.map +0 -1
  21. package/dist-server/engine/live-feed-registry.js.map +0 -1
  22. package/dist-server/engine/load-meter.js.map +0 -1
  23. package/dist-server/engine/local-declarations.js.map +0 -1
  24. package/dist-server/engine/log.js.map +0 -1
  25. package/dist-server/engine/loop-lag.js.map +0 -1
  26. package/dist-server/engine/measured-estimator.js.map +0 -1
  27. package/dist-server/engine/measured-yield.js.map +0 -1
  28. package/dist-server/engine/model-basis.js.map +0 -1
  29. package/dist-server/engine/model-gap.js.map +0 -1
  30. package/dist-server/engine/model-refusal.js.map +0 -1
  31. package/dist-server/engine/model-vocabulary.js.map +0 -1
  32. package/dist-server/engine/oee-accumulator.js.map +0 -1
  33. package/dist-server/engine/operation-basis.js.map +0 -1
  34. package/dist-server/engine/producer-guard.js.map +0 -1
  35. package/dist-server/engine/property-effects.js.map +0 -1
  36. package/dist-server/engine/read-time.js.map +0 -1
  37. package/dist-server/engine/restart-policy.js.map +0 -1
  38. package/dist-server/engine/runtime-key.js.map +0 -1
  39. package/dist-server/engine/spec-coverage.js.map +0 -1
  40. package/dist-server/engine/stage-path.js.map +0 -1
  41. package/dist-server/engine/state-axes.js.map +0 -1
  42. package/dist-server/engine/structure-diff.js.map +0 -1
  43. package/dist-server/engine/travel-estimator.js.map +0 -1
  44. package/dist-server/engine/twin-engine.js.map +0 -1
  45. package/dist-server/engine/twin-state-channel.js.map +0 -1
  46. package/dist-server/engine/warm-start.js.map +0 -1
  47. package/dist-server/index.js.map +0 -1
  48. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js.map +0 -1
  49. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +0 -1
  50. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +0 -1
  51. package/dist-server/migrations/1786300000000-IndexEventTypeByTime.js.map +0 -1
  52. package/dist-server/migrations/1786400000000-IndexEventCreatedAt.js.map +0 -1
  53. package/dist-server/migrations/1786500000000-CreateTwinSubjectEvents.js.map +0 -1
  54. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +0 -1
  55. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +0 -1
  56. package/dist-server/migrations/1786800000000-CreateTwinReferenceReconciliations.js.map +0 -1
  57. package/dist-server/migrations/index.js.map +0 -1
  58. package/dist-server/routes.js.map +0 -1
  59. package/dist-server/service/actuation/actuation-advisor.js.map +0 -1
  60. package/dist-server/service/actuation/actuation-outcome.js.map +0 -1
  61. package/dist-server/service/actuation/actuation-rule-resolver.js.map +0 -1
  62. package/dist-server/service/actuation/actuation-rule.js.map +0 -1
  63. package/dist-server/service/actuation/approval-gateway.js.map +0 -1
  64. package/dist-server/service/actuation/catch-up-effects.js.map +0 -1
  65. package/dist-server/service/actuation/command-dispatcher.js.map +0 -1
  66. package/dist-server/service/actuation/command-store.js.map +0 -1
  67. package/dist-server/service/actuation/dispatch-after-approval.js.map +0 -1
  68. package/dist-server/service/actuation/effect-match.js.map +0 -1
  69. package/dist-server/service/actuation/index.js.map +0 -1
  70. package/dist-server/service/actuation/retry-plan.js.map +0 -1
  71. package/dist-server/service/actuation/rule-evaluate.js.map +0 -1
  72. package/dist-server/service/actuation/rule-loop.js.map +0 -1
  73. package/dist-server/service/actuation/rule-runner.js.map +0 -1
  74. package/dist-server/service/actuation/rule-wiring.js.map +0 -1
  75. package/dist-server/service/actuation/settle-effects.js.map +0 -1
  76. package/dist-server/service/actuation/sweep-dispatch.js.map +0 -1
  77. package/dist-server/service/actuation/twin-command-resolver.js.map +0 -1
  78. package/dist-server/service/actuation/twin-command.js.map +0 -1
  79. package/dist-server/service/actuation/twin-now.js.map +0 -1
  80. package/dist-server/service/index.js.map +0 -1
  81. package/dist-server/service/reference/actuation-routing.js.map +0 -1
  82. package/dist-server/service/reference/actuation-target.js.map +0 -1
  83. package/dist-server/service/reference/actuation-verification.js.map +0 -1
  84. package/dist-server/service/reference/attention-lane.js.map +0 -1
  85. package/dist-server/service/reference/connection-portability-resolver.js.map +0 -1
  86. package/dist-server/service/reference/connection-portability.js.map +0 -1
  87. package/dist-server/service/reference/control-routing.js.map +0 -1
  88. package/dist-server/service/reference/discovery-result.js.map +0 -1
  89. package/dist-server/service/reference/fill-cadence.js.map +0 -1
  90. package/dist-server/service/reference/fill-dedupe.js.map +0 -1
  91. package/dist-server/service/reference/fill-loop.js.map +0 -1
  92. package/dist-server/service/reference/fill-span.js.map +0 -1
  93. package/dist-server/service/reference/hook-contract.js.map +0 -1
  94. package/dist-server/service/reference/hook-rejected.js.map +0 -1
  95. package/dist-server/service/reference/hook-store.js.map +0 -1
  96. package/dist-server/service/reference/index.js.map +0 -1
  97. package/dist-server/service/reference/ingest-space.js.map +0 -1
  98. package/dist-server/service/reference/knob-defaults.js.map +0 -1
  99. package/dist-server/service/reference/live-cadence.js.map +0 -1
  100. package/dist-server/service/reference/live-feed-lease.js.map +0 -1
  101. package/dist-server/service/reference/live-ingest.js.map +0 -1
  102. package/dist-server/service/reference/reference-adapter.js.map +0 -1
  103. package/dist-server/service/reference/reference-assessment.js.map +0 -1
  104. package/dist-server/service/reference/reference-fill-run.js.map +0 -1
  105. package/dist-server/service/reference/reference-fill.js.map +0 -1
  106. package/dist-server/service/reference/reference-hook.js.map +0 -1
  107. package/dist-server/service/reference/reference-link-groups.js.map +0 -1
  108. package/dist-server/service/reference/reference-links.js.map +0 -1
  109. package/dist-server/service/reference/reference-live.js.map +0 -1
  110. package/dist-server/service/reference/reference-master.js.map +0 -1
  111. package/dist-server/service/reference/reference-probe.js.map +0 -1
  112. package/dist-server/service/reference/reference-progress-channel.js.map +0 -1
  113. package/dist-server/service/reference/reference-progress-subscription.js.map +0 -1
  114. package/dist-server/service/reference/reference-progress.js.map +0 -1
  115. package/dist-server/service/reference/reference-reconciliation-query.js.map +0 -1
  116. package/dist-server/service/reference/reference-reconciliation-writer.js.map +0 -1
  117. package/dist-server/service/reference/reference-reconciliation.js.map +0 -1
  118. package/dist-server/service/reference/reference-registry.js.map +0 -1
  119. package/dist-server/service/reference/reference-resolver.js.map +0 -1
  120. package/dist-server/service/reference/template-registry.js.map +0 -1
  121. package/dist-server/service/reference/twin-reference-reconciliation.js.map +0 -1
  122. package/dist-server/service/reference/twin-reference.js.map +0 -1
  123. package/dist-server/service/twin-attention/index.js.map +0 -1
  124. package/dist-server/service/twin-attention/twin-attention-query.js.map +0 -1
  125. package/dist-server/service/twin-audit/command-audit.js.map +0 -1
  126. package/dist-server/service/twin-audit/index.js.map +0 -1
  127. package/dist-server/service/twin-audit/twin-audit-event.js.map +0 -1
  128. package/dist-server/service/twin-audit/twin-audit-query.js.map +0 -1
  129. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +0 -1
  130. package/dist-server/service/twin-backfill/backfill-shape.js.map +0 -1
  131. package/dist-server/service/twin-backfill/index.js.map +0 -1
  132. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +0 -1
  133. package/dist-server/service/twin-control/index.js.map +0 -1
  134. package/dist-server/service/twin-control/twin-control-mutation.js.map +0 -1
  135. package/dist-server/service/twin-event/index.js.map +0 -1
  136. package/dist-server/service/twin-event/journal-count.js.map +0 -1
  137. package/dist-server/service/twin-event/twin-event-keys.js.map +0 -1
  138. package/dist-server/service/twin-event/twin-event-type.js.map +0 -1
  139. package/dist-server/service/twin-event/twin-event.js.map +0 -1
  140. package/dist-server/service/twin-forecast/forecast-metrics.js.map +0 -1
  141. package/dist-server/service/twin-forecast/forecast-tuning.js.map +0 -1
  142. package/dist-server/service/twin-forecast/gap-analytics.js.map +0 -1
  143. package/dist-server/service/twin-forecast/index.js.map +0 -1
  144. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +0 -1
  145. package/dist-server/service/twin-ingest-window/index.js.map +0 -1
  146. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +0 -1
  147. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +0 -1
  148. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +0 -1
  149. package/dist-server/service/twin-instance/index.js.map +0 -1
  150. package/dist-server/service/twin-instance/twin-instance.js.map +0 -1
  151. package/dist-server/service/twin-journal/event-type-split-shape.js.map +0 -1
  152. package/dist-server/service/twin-journal/event-type-split.js.map +0 -1
  153. package/dist-server/service/twin-journal/index.js.map +0 -1
  154. package/dist-server/service/twin-journal/journal-order.js.map +0 -1
  155. package/dist-server/service/twin-journal/twin-journal-query.js.map +0 -1
  156. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +0 -1
  157. package/dist-server/service/twin-lifecycle/index.js.map +0 -1
  158. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +0 -1
  159. package/dist-server/service/twin-metrics/index.js.map +0 -1
  160. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +0 -1
  161. package/dist-server/service/twin-model/axis-journal-evidence.js.map +0 -1
  162. package/dist-server/service/twin-model/choose-recipe.js.map +0 -1
  163. package/dist-server/service/twin-model/epcis-coverage.js.map +0 -1
  164. package/dist-server/service/twin-model/external-resolver.js.map +0 -1
  165. package/dist-server/service/twin-model/iec61850-coverage.js.map +0 -1
  166. package/dist-server/service/twin-model/index.js.map +0 -1
  167. package/dist-server/service/twin-model/isa95-coverage.js.map +0 -1
  168. package/dist-server/service/twin-model/item-ref.js.map +0 -1
  169. package/dist-server/service/twin-model/name-index.js.map +0 -1
  170. package/dist-server/service/twin-model/project-structure.js.map +0 -1
  171. package/dist-server/service/twin-model/standard-coverage.js.map +0 -1
  172. package/dist-server/service/twin-model/status-tally.js.map +0 -1
  173. package/dist-server/service/twin-model/twin-equipment.js.map +0 -1
  174. package/dist-server/service/twin-model/twin-lineage-query.js.map +0 -1
  175. package/dist-server/service/twin-model/twin-location.js.map +0 -1
  176. package/dist-server/service/twin-model/twin-model-item-query.js.map +0 -1
  177. package/dist-server/service/twin-model/twin-model-mutation.js.map +0 -1
  178. package/dist-server/service/twin-model/twin-model-query.js.map +0 -1
  179. package/dist-server/service/twin-model/twin-model-tree-query.js.map +0 -1
  180. package/dist-server/service/twin-model/twin-operation.js.map +0 -1
  181. package/dist-server/service/twin-readiness/index.js.map +0 -1
  182. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +0 -1
  183. package/dist-server/service/twin-space/area-resolver.js.map +0 -1
  184. package/dist-server/service/twin-space/index.js.map +0 -1
  185. package/dist-server/service/twin-space/move-space.js.map +0 -1
  186. package/dist-server/service/twin-space/representation-role.js.map +0 -1
  187. package/dist-server/service/twin-space/space-integrity.js.map +0 -1
  188. package/dist-server/service/twin-space/twin-area.js.map +0 -1
  189. package/dist-server/service/twin-space/twin-space-area.js.map +0 -1
  190. package/dist-server/service/twin-space/twin-space-representation.js.map +0 -1
  191. package/dist-server/service/twin-space/twin-space-resolver.js.map +0 -1
  192. package/dist-server/service/twin-space/twin-space.js.map +0 -1
  193. package/dist-server/service/twin-state/index.js.map +0 -1
  194. package/dist-server/service/twin-state/twin-state-subscription.js.map +0 -1
  195. package/dist-server/service/twin-structure/index.js.map +0 -1
  196. package/dist-server/service/twin-structure/twin-structure.js.map +0 -1
  197. package/dist-server/service/twin-subject/event-subjects.js.map +0 -1
  198. package/dist-server/service/twin-subject/index.js.map +0 -1
  199. package/dist-server/service/twin-subject/item-resolver.js.map +0 -1
  200. package/dist-server/service/twin-subject/subject-query-shape.js.map +0 -1
  201. package/dist-server/service/twin-subject/twin-subject-backfill.js.map +0 -1
  202. package/dist-server/service/twin-subject/twin-subject-event.js.map +0 -1
  203. package/dist-server/service/twin-subject/twin-subject-fast-path.js.map +0 -1
  204. package/dist-server/service/twin-subject/twin-subject-query.js.map +0 -1
  205. package/dist-server/service/twin-subject/twin-subject-writer.js.map +0 -1
  206. package/dist-server/service/twin-target/index.js.map +0 -1
  207. package/dist-server/service/twin-target/twin-target-resolver.js.map +0 -1
  208. package/dist-server/service/twin-target/twin-target.js.map +0 -1
  209. package/dist-shared/axis-read.js.map +0 -1
  210. package/dist-shared/entity-delta.js.map +0 -1
  211. package/dist-shared/kpi-broadcast.js.map +0 -1
  212. package/dist-shared/touched-items.js.map +0 -1
  213. package/dist-shared/twin-level.js.map +0 -1
  214. package/server/engine/attention-digest.ts +0 -102
  215. package/server/engine/canonical-ingest.ts +0 -511
  216. package/server/engine/checkpoint-fold.ts +0 -12
  217. package/server/engine/command-routing.ts +0 -67
  218. package/server/engine/declared-stimulus.ts +0 -66
  219. package/server/engine/energy-topology.ts +0 -189
  220. package/server/engine/event-time.ts +0 -96
  221. package/server/engine/index.ts +0 -51
  222. package/server/engine/ingest-dedupe.ts +0 -24
  223. package/server/engine/ingest-health.ts +0 -24
  224. package/server/engine/integration-coverage.ts +0 -147
  225. package/server/engine/integration-probes.ts +0 -144
  226. package/server/engine/integration-runner.ts +0 -95
  227. package/server/engine/integration-target-profile.ts +0 -103
  228. package/server/engine/kpi-baseline.ts +0 -202
  229. package/server/engine/kpi-fold.ts +0 -1516
  230. package/server/engine/kpi-query.ts +0 -1210
  231. package/server/engine/kpi-target.ts +0 -248
  232. package/server/engine/live-attentions.ts +0 -30
  233. package/server/engine/live-feed-registry.ts +0 -59
  234. package/server/engine/load-meter.ts +0 -393
  235. package/server/engine/local-declarations.ts +0 -779
  236. package/server/engine/log.ts +0 -72
  237. package/server/engine/loop-lag.ts +0 -120
  238. package/server/engine/measured-estimator.ts +0 -91
  239. package/server/engine/measured-yield.ts +0 -89
  240. package/server/engine/model-basis.ts +0 -162
  241. package/server/engine/model-gap.ts +0 -168
  242. package/server/engine/model-refusal.ts +0 -31
  243. package/server/engine/model-vocabulary.ts +0 -82
  244. package/server/engine/oee-accumulator.ts +0 -195
  245. package/server/engine/operation-basis.ts +0 -131
  246. package/server/engine/producer-guard.ts +0 -64
  247. package/server/engine/property-effects.ts +0 -216
  248. package/server/engine/read-time.ts +0 -77
  249. package/server/engine/restart-policy.ts +0 -55
  250. package/server/engine/runtime-key.ts +0 -58
  251. package/server/engine/spec-coverage.ts +0 -105
  252. package/server/engine/stage-path.ts +0 -172
  253. package/server/engine/state-axes.ts +0 -60
  254. package/server/engine/structure-diff.ts +0 -88
  255. package/server/engine/travel-estimator.ts +0 -138
  256. package/server/engine/twin-engine.ts +0 -4979
  257. package/server/engine/twin-state-channel.ts +0 -39
  258. package/server/engine/warm-start.ts +0 -265
  259. package/server/index.ts +0 -90
  260. package/server/migrations/1786000000000-RenameTwinInstanceBoardToModel.ts +0 -43
  261. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +0 -54
  262. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +0 -53
  263. package/server/migrations/1786300000000-IndexEventTypeByTime.ts +0 -48
  264. package/server/migrations/1786400000000-IndexEventCreatedAt.ts +0 -38
  265. package/server/migrations/1786500000000-CreateTwinSubjectEvents.ts +0 -93
  266. package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +0 -66
  267. package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +0 -82
  268. package/server/migrations/1786800000000-CreateTwinReferenceReconciliations.ts +0 -39
  269. package/server/migrations/index.ts +0 -25
  270. package/server/routes.ts +0 -75
  271. package/server/service/actuation/actuation-advisor.ts +0 -131
  272. package/server/service/actuation/actuation-outcome.ts +0 -97
  273. package/server/service/actuation/actuation-rule-resolver.ts +0 -147
  274. package/server/service/actuation/actuation-rule.ts +0 -139
  275. package/server/service/actuation/approval-gateway.ts +0 -44
  276. package/server/service/actuation/catch-up-effects.ts +0 -152
  277. package/server/service/actuation/command-dispatcher.ts +0 -474
  278. package/server/service/actuation/command-store.ts +0 -236
  279. package/server/service/actuation/dispatch-after-approval.ts +0 -71
  280. package/server/service/actuation/effect-match.ts +0 -124
  281. package/server/service/actuation/index.ts +0 -27
  282. package/server/service/actuation/retry-plan.ts +0 -145
  283. package/server/service/actuation/rule-evaluate.ts +0 -352
  284. package/server/service/actuation/rule-loop.ts +0 -160
  285. package/server/service/actuation/rule-runner.ts +0 -216
  286. package/server/service/actuation/rule-wiring.ts +0 -84
  287. package/server/service/actuation/settle-effects.ts +0 -115
  288. package/server/service/actuation/sweep-dispatch.ts +0 -179
  289. package/server/service/actuation/twin-command-resolver.ts +0 -387
  290. package/server/service/actuation/twin-command.ts +0 -232
  291. package/server/service/actuation/twin-now.ts +0 -97
  292. package/server/service/index.ts +0 -100
  293. package/server/service/reference/actuation-routing.ts +0 -192
  294. package/server/service/reference/actuation-target.ts +0 -163
  295. package/server/service/reference/actuation-verification.ts +0 -85
  296. package/server/service/reference/attention-lane.ts +0 -21
  297. package/server/service/reference/connection-portability-resolver.ts +0 -171
  298. package/server/service/reference/connection-portability.ts +0 -329
  299. package/server/service/reference/control-routing.ts +0 -62
  300. package/server/service/reference/discovery-result.ts +0 -95
  301. package/server/service/reference/fill-cadence.ts +0 -29
  302. package/server/service/reference/fill-dedupe.ts +0 -66
  303. package/server/service/reference/fill-loop.ts +0 -153
  304. package/server/service/reference/fill-span.ts +0 -31
  305. package/server/service/reference/hook-contract.ts +0 -364
  306. package/server/service/reference/hook-rejected.ts +0 -53
  307. package/server/service/reference/hook-store.ts +0 -28
  308. package/server/service/reference/index.ts +0 -18
  309. package/server/service/reference/ingest-space.ts +0 -104
  310. package/server/service/reference/knob-defaults.ts +0 -59
  311. package/server/service/reference/live-cadence.ts +0 -129
  312. package/server/service/reference/live-feed-lease.ts +0 -56
  313. package/server/service/reference/live-ingest.ts +0 -94
  314. package/server/service/reference/reference-adapter.ts +0 -985
  315. package/server/service/reference/reference-assessment.ts +0 -250
  316. package/server/service/reference/reference-fill-run.ts +0 -179
  317. package/server/service/reference/reference-fill.ts +0 -223
  318. package/server/service/reference/reference-hook.ts +0 -479
  319. package/server/service/reference/reference-link-groups.ts +0 -21
  320. package/server/service/reference/reference-links.ts +0 -77
  321. package/server/service/reference/reference-live.ts +0 -420
  322. package/server/service/reference/reference-master.ts +0 -999
  323. package/server/service/reference/reference-probe.ts +0 -264
  324. package/server/service/reference/reference-progress-channel.ts +0 -26
  325. package/server/service/reference/reference-progress-subscription.ts +0 -78
  326. package/server/service/reference/reference-progress.ts +0 -95
  327. package/server/service/reference/reference-reconciliation-query.ts +0 -28
  328. package/server/service/reference/reference-reconciliation-writer.ts +0 -38
  329. package/server/service/reference/reference-reconciliation.ts +0 -55
  330. package/server/service/reference/reference-registry.ts +0 -17
  331. package/server/service/reference/reference-resolver.ts +0 -969
  332. package/server/service/reference/template-registry.ts +0 -85
  333. package/server/service/reference/twin-reference-reconciliation.ts +0 -27
  334. package/server/service/reference/twin-reference.ts +0 -190
  335. package/server/service/twin-attention/index.ts +0 -3
  336. package/server/service/twin-attention/twin-attention-query.ts +0 -70
  337. package/server/service/twin-audit/command-audit.ts +0 -114
  338. package/server/service/twin-audit/index.ts +0 -5
  339. package/server/service/twin-audit/twin-audit-event.ts +0 -158
  340. package/server/service/twin-audit/twin-audit-query.ts +0 -72
  341. package/server/service/twin-backfill/backfill-period-facts.ts +0 -168
  342. package/server/service/twin-backfill/backfill-shape.ts +0 -86
  343. package/server/service/twin-backfill/index.ts +0 -3
  344. package/server/service/twin-backfill/twin-backfill-resolver.ts +0 -35
  345. package/server/service/twin-control/index.ts +0 -3
  346. package/server/service/twin-control/twin-control-mutation.ts +0 -150
  347. package/server/service/twin-event/index.ts +0 -3
  348. package/server/service/twin-event/journal-count.ts +0 -53
  349. package/server/service/twin-event/twin-event-keys.ts +0 -215
  350. package/server/service/twin-event/twin-event-type.ts +0 -68
  351. package/server/service/twin-event/twin-event.ts +0 -250
  352. package/server/service/twin-forecast/forecast-metrics.ts +0 -106
  353. package/server/service/twin-forecast/forecast-tuning.ts +0 -40
  354. package/server/service/twin-forecast/gap-analytics.ts +0 -139
  355. package/server/service/twin-forecast/index.ts +0 -3
  356. package/server/service/twin-forecast/twin-forecast-query.ts +0 -486
  357. package/server/service/twin-ingest-window/index.ts +0 -7
  358. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +0 -125
  359. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +0 -57
  360. package/server/service/twin-ingest-window/twin-ingest-window.ts +0 -113
  361. package/server/service/twin-instance/index.ts +0 -3
  362. package/server/service/twin-instance/twin-instance.ts +0 -199
  363. package/server/service/twin-journal/event-type-split-shape.ts +0 -112
  364. package/server/service/twin-journal/event-type-split.ts +0 -144
  365. package/server/service/twin-journal/index.ts +0 -3
  366. package/server/service/twin-journal/journal-order.ts +0 -20
  367. package/server/service/twin-journal/twin-journal-query.ts +0 -615
  368. package/server/service/twin-lifecycle/domain-catalog.ts +0 -51
  369. package/server/service/twin-lifecycle/index.ts +0 -3
  370. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +0 -285
  371. package/server/service/twin-metrics/index.ts +0 -3
  372. package/server/service/twin-metrics/twin-metrics-query.ts +0 -172
  373. package/server/service/twin-model/axis-journal-evidence.ts +0 -57
  374. package/server/service/twin-model/choose-recipe.ts +0 -31
  375. package/server/service/twin-model/epcis-coverage.ts +0 -251
  376. package/server/service/twin-model/external-resolver.ts +0 -173
  377. package/server/service/twin-model/iec61850-coverage.ts +0 -111
  378. package/server/service/twin-model/index.ts +0 -25
  379. package/server/service/twin-model/isa95-coverage.ts +0 -320
  380. package/server/service/twin-model/item-ref.ts +0 -80
  381. package/server/service/twin-model/name-index.ts +0 -94
  382. package/server/service/twin-model/project-structure.ts +0 -213
  383. package/server/service/twin-model/standard-coverage.ts +0 -35
  384. package/server/service/twin-model/status-tally.ts +0 -37
  385. package/server/service/twin-model/twin-equipment.ts +0 -191
  386. package/server/service/twin-model/twin-lineage-query.ts +0 -327
  387. package/server/service/twin-model/twin-location.ts +0 -147
  388. package/server/service/twin-model/twin-model-item-query.ts +0 -1029
  389. package/server/service/twin-model/twin-model-mutation.ts +0 -503
  390. package/server/service/twin-model/twin-model-query.ts +0 -792
  391. package/server/service/twin-model/twin-model-tree-query.ts +0 -211
  392. package/server/service/twin-model/twin-operation.ts +0 -157
  393. package/server/service/twin-readiness/index.ts +0 -3
  394. package/server/service/twin-readiness/twin-readiness-query.ts +0 -96
  395. package/server/service/twin-space/area-resolver.ts +0 -75
  396. package/server/service/twin-space/index.ts +0 -8
  397. package/server/service/twin-space/move-space.ts +0 -262
  398. package/server/service/twin-space/representation-role.ts +0 -15
  399. package/server/service/twin-space/space-integrity.ts +0 -293
  400. package/server/service/twin-space/twin-area.ts +0 -63
  401. package/server/service/twin-space/twin-space-area.ts +0 -66
  402. package/server/service/twin-space/twin-space-representation.ts +0 -99
  403. package/server/service/twin-space/twin-space-resolver.ts +0 -582
  404. package/server/service/twin-space/twin-space.ts +0 -148
  405. package/server/service/twin-state/index.ts +0 -3
  406. package/server/service/twin-state/twin-state-subscription.ts +0 -62
  407. package/server/service/twin-structure/index.ts +0 -3
  408. package/server/service/twin-structure/twin-structure.ts +0 -96
  409. package/server/service/twin-subject/event-subjects.ts +0 -162
  410. package/server/service/twin-subject/index.ts +0 -10
  411. package/server/service/twin-subject/item-resolver.ts +0 -92
  412. package/server/service/twin-subject/subject-query-shape.ts +0 -84
  413. package/server/service/twin-subject/twin-subject-backfill.ts +0 -196
  414. package/server/service/twin-subject/twin-subject-event.ts +0 -101
  415. package/server/service/twin-subject/twin-subject-fast-path.ts +0 -346
  416. package/server/service/twin-subject/twin-subject-query.ts +0 -210
  417. package/server/service/twin-subject/twin-subject-writer.ts +0 -189
  418. package/server/service/twin-target/index.ts +0 -5
  419. package/server/service/twin-target/twin-target-resolver.ts +0 -142
  420. package/server/service/twin-target/twin-target.ts +0 -97
  421. package/server/types/global.d.ts +0 -22
  422. package/shared/axis-read.ts +0 -28
  423. package/shared/entity-delta.ts +0 -335
  424. package/shared/kpi-broadcast.ts +0 -13
  425. package/shared/touched-items.ts +0 -73
  426. package/shared/twin-level.ts +0 -48
  427. package/test/ack-shape-alignment.test.ts +0 -97
  428. package/test/actuation-advisor.test.ts +0 -55
  429. package/test/actuation-approval-door.test.ts +0 -411
  430. package/test/actuation-dispatch.test.ts +0 -209
  431. package/test/actuation-effect.test.ts +0 -162
  432. package/test/actuation-outcome.test.ts +0 -152
  433. package/test/actuation-rule-runner.test.ts +0 -281
  434. package/test/actuation-rule.test.ts +0 -266
  435. package/test/actuation-seam.test.ts +0 -255
  436. package/test/actuation-settle.test.ts +0 -164
  437. package/test/actuation-target.test.ts +0 -145
  438. package/test/actuation-verification-state.test.ts +0 -139
  439. package/test/adopt-structure-live.test.ts +0 -133
  440. package/test/aggregate-not-findone.test.ts +0 -112
  441. package/test/area-resolver-db.test.ts +0 -110
  442. package/test/attention-digest.test.ts +0 -161
  443. package/test/axis-declaration-passthrough-db.test.ts +0 -152
  444. package/test/axis-journal-evidence.test.ts +0 -71
  445. package/test/axis-read.test.ts +0 -188
  446. package/test/backfill-shape.test.ts +0 -89
  447. package/test/boot-resume.test.ts +0 -287
  448. package/test/broadcast-cost-baseline.test.ts +0 -201
  449. package/test/broadcast-period.test.ts +0 -58
  450. package/test/broadcast-queue-overflow.test.ts +0 -244
  451. package/test/canonical-ingest-vocabularies.test.ts +0 -451
  452. package/test/canonical-quantity-door.test.ts +0 -61
  453. package/test/capability-mapping.test.ts +0 -89
  454. package/test/checkpoint-refuses-empty.test.ts +0 -103
  455. package/test/choose-recipe.test.ts +0 -117
  456. package/test/column-type-portability.test.ts +0 -122
  457. package/test/command-routing.test.ts +0 -61
  458. package/test/command-store-writes-columns.test.ts +0 -126
  459. package/test/connection-portability-doors.test.ts +0 -87
  460. package/test/connector-capability-declaration.test.ts +0 -195
  461. package/test/contract-layer-guard.test.ts +0 -48
  462. package/test/control-capability.test.ts +0 -98
  463. package/test/cursor-stall-not-read-failure.test.ts +0 -126
  464. package/test/declaration-reaches-model.test.ts +0 -226
  465. package/test/declared-location-types.test.ts +0 -89
  466. package/test/declared-stimulus.test.ts +0 -88
  467. package/test/dedupe-transformation.test.ts +0 -112
  468. package/test/discovery-result.test.ts +0 -75
  469. package/test/dispatch-after-approval-source.test.ts +0 -81
  470. package/test/duration-estimators.test.ts +0 -144
  471. package/test/energy-topology.test.ts +0 -113
  472. package/test/entity-delta.test.ts +0 -352
  473. package/test/equipment-identity-reaches.test.ts +0 -57
  474. package/test/event-subjects.test.ts +0 -148
  475. package/test/event-time-column.test.ts +0 -67
  476. package/test/event-type-split.test.ts +0 -178
  477. package/test/external-resolver.test.ts +0 -177
  478. package/test/fact-scope-wiring.test.ts +0 -96
  479. package/test/fill-dedupe.test.ts +0 -59
  480. package/test/fill-loop.test.ts +0 -35
  481. package/test/first-envelope.test.ts +0 -153
  482. package/test/fold-gap-speaks.test.ts +0 -53
  483. package/test/fold-shift-dst.test.ts +0 -110
  484. package/test/forecast-metrics.test.ts +0 -157
  485. package/test/forecast-tuning.test.ts +0 -42
  486. package/test/gap-analytics.test.ts +0 -113
  487. package/test/generated-not-counted.test.ts +0 -99
  488. package/test/guard-roots.ts +0 -76
  489. package/test/home-in-area.test.ts +0 -90
  490. package/test/hook-address.test.ts +0 -28
  491. package/test/hook-rejected-shape.test.ts +0 -86
  492. package/test/hook-response-sum.test.ts +0 -193
  493. package/test/hook-sequence.test.ts +0 -333
  494. package/test/ingest-bench.test.ts +0 -70
  495. package/test/ingest-dedupe.test.ts +0 -56
  496. package/test/ingest-expected-quiet.test.ts +0 -111
  497. package/test/ingest-health-engine.test.ts +0 -248
  498. package/test/ingest-health-wiring.test.ts +0 -120
  499. package/test/ingest-health.test.ts +0 -306
  500. package/test/ingest-history.test.ts +0 -247
  501. package/test/ingest-idempotent.test.ts +0 -211
  502. package/test/ingest-reconcile-four-ways.test.ts +0 -97
  503. package/test/ingest-rules-header.test.ts +0 -100
  504. package/test/ingest-running-guard.test.ts +0 -136
  505. package/test/ingest-space.test.ts +0 -78
  506. package/test/ingest-window-rolls-unobserved.test.ts +0 -72
  507. package/test/ingest-wiring-guard.test.ts +0 -197
  508. package/test/instance-cache-lifecycle.test.ts +0 -110
  509. package/test/intake-mapping-parity.test.ts +0 -175
  510. package/test/integration-probes.test.ts +0 -103
  511. package/test/integration-runner.test.ts +0 -95
  512. package/test/item-ref.test.ts +0 -78
  513. package/test/journal-order-axis.test.ts +0 -80
  514. package/test/journal-read-discipline.test.ts +0 -186
  515. package/test/journal-retention.test.ts +0 -275
  516. package/test/journal-sort-axis.test.ts +0 -142
  517. package/test/journal-write-door.test.ts +0 -110
  518. package/test/journal-write-trend.test.ts +0 -115
  519. package/test/kernel-kind-guard.test.ts +0 -98
  520. package/test/knob-defaults.test.ts +0 -72
  521. package/test/kpi-baseline-db.test.ts +0 -216
  522. package/test/kpi-baseline.test.ts +0 -196
  523. package/test/kpi-fold.test.ts +0 -1456
  524. package/test/kpi-query-bench.test.ts +0 -130
  525. package/test/lens-own-places.test.ts +0 -50
  526. package/test/lineage-survives-restart.test.ts +0 -234
  527. package/test/live-cadence.test.ts +0 -170
  528. package/test/live-cursor-wiring.test.ts +0 -87
  529. package/test/live-feed-lease.test.ts +0 -63
  530. package/test/live-feed-registry.test.ts +0 -75
  531. package/test/live-kernel-facts.test.ts +0 -161
  532. package/test/live-mirror-parity.test.ts +0 -286
  533. package/test/load-meter.test.ts +0 -328
  534. package/test/local-declarations.test.ts +0 -662
  535. package/test/log-stamp.test.ts +0 -59
  536. package/test/loop-lag.test.ts +0 -82
  537. package/test/master-to-twin.test.ts +0 -279
  538. package/test/measured-yield.test.ts +0 -90
  539. package/test/merge-streams.test.ts +0 -70
  540. package/test/mirror-resumes-from-checkpoint.test.ts +0 -192
  541. package/test/model-basis.test.ts +0 -141
  542. package/test/model-gap.test.ts +0 -153
  543. package/test/model-refusal.test.ts +0 -75
  544. package/test/model-vocabulary.test.ts +0 -114
  545. package/test/move-space.test.ts +0 -149
  546. package/test/mutation-gate.test.ts +0 -122
  547. package/test/oee-accumulator.test.ts +0 -204
  548. package/test/oee-site-calendar.test.ts +0 -146
  549. package/test/open-doors.test.ts +0 -197
  550. package/test/operation-basis.test.ts +0 -127
  551. package/test/operational-vocabulary.test.ts +0 -108
  552. package/test/operations-capability-db.test.ts +0 -165
  553. package/test/order-and-transaction-split.test.ts +0 -107
  554. package/test/order-fold-host.test.ts +0 -112
  555. package/test/producer-guard.test.ts +0 -66
  556. package/test/project-structure-db.test.ts +0 -226
  557. package/test/projection-reaches-screen.test.ts +0 -277
  558. package/test/projection-reads-declared.test.ts +0 -109
  559. package/test/property-effects.test.ts +0 -128
  560. package/test/provenance-promotion.test.ts +0 -99
  561. package/test/read-failure-ledger.test.ts +0 -106
  562. package/test/read-failure-visible.test.ts +0 -145
  563. package/test/read-time.test.ts +0 -118
  564. package/test/reference-fill.test.ts +0 -235
  565. package/test/reference-grounding.test.ts +0 -70
  566. package/test/reference-hook.test.ts +0 -177
  567. package/test/reference-links.test.ts +0 -56
  568. package/test/reference-reconciliation.test.ts +0 -39
  569. package/test/registry-key-guard.test.ts +0 -80
  570. package/test/rejection-carries-event-id.test.ts +0 -101
  571. package/test/rename-twin.test.ts +0 -89
  572. package/test/resolve-ts-siblings.mjs +0 -52
  573. package/test/restart-policy.test.ts +0 -111
  574. package/test/resync-origin-site.test.ts +0 -83
  575. package/test/retry-plan.test.ts +0 -139
  576. package/test/revision-axis.test.ts +0 -110
  577. package/test/runtime-key.test.ts +0 -66
  578. package/test/save-intake-collision.test.ts +0 -91
  579. package/test/scale-twin-bench.test.ts +0 -103
  580. package/test/secrets-at-rest.test.ts +0 -135
  581. package/test/snapshot-freshness.test.ts +0 -60
  582. package/test/source-outcome-audit.test.ts +0 -104
  583. package/test/space-integrity.test.ts +0 -223
  584. package/test/space-representation.test.ts +0 -66
  585. package/test/space-scope-includes-stopped.test.ts +0 -55
  586. package/test/spec-coverage.test.ts +0 -113
  587. package/test/stage-path.test.ts +0 -95
  588. package/test/standard-coverage.test.ts +0 -148
  589. package/test/state-axes.test.ts +0 -74
  590. package/test/status-tally.test.ts +0 -55
  591. package/test/streamline-e2e.test.ts +0 -76
  592. package/test/structure-revision-db.test.ts +0 -315
  593. package/test/subject-fast-path.test.ts +0 -148
  594. package/test/subject-read-model.test.ts +0 -175
  595. package/test/sweep-dispatch.test.ts +0 -191
  596. package/test/tenant-registry-db.test.ts +0 -149
  597. package/test/time-range.test.ts +0 -152
  598. package/test/touched-items.test.ts +0 -61
  599. package/test/twin-audit.test.ts +0 -83
  600. package/test/twin-event-keys.test.ts +0 -193
  601. package/test/twin-model-item-db.test.ts +0 -319
  602. package/test/twin-model-parent-relation-db.test.ts +0 -125
  603. package/test/twin-model-tree-db.test.ts +0 -209
  604. package/test/twin-origin-resync.test.ts +0 -133
  605. package/test/twin-state-channel.test.ts +0 -111
  606. package/test/vocabulary-guard.test.ts +0 -43
  607. package/test/warm-start-seam.test.ts +0 -162
  608. package/test/warm-start.test.ts +0 -297
  609. package/test/withheld-door.test.ts +0 -95
  610. package/test/yield-loop.test.ts +0 -199
  611. package/tsconfig.json +0 -15
  612. package/tsconfig.shared.json +0 -23
  613. package/tsconfig.shared.tsbuildinfo +0 -1
  614. package/tsconfig.tsbuildinfo +0 -1
@@ -1,1456 +0,0 @@
1
- /*
2
- * 업무 KPI 폴드 검증 — 저널을 계산해 시간창 지표를 만드는 규칙을 고정한다.
3
- *
4
- * 무엇을 지키는가:
5
- * ① **성과는 완료 시점으로 센다** — 시작 시점으로 세면 진행 중인 것이 섞여 처리량이 부풀려진다.
6
- * ② **셈에서 빠진 것을 밝힌다**(unpaired) — 시작 이벤트를 못 찾은 완료를 알리지 않고 버리면 평균이 거짓이 된다.
7
- * ③ **없는 것을 0 으로 꾸미지 않는다** — 저널이 비었으면 eventsSeen 0 으로 그 사실이 보인다.
8
- * ④ 시간축은 eventTime 차분 — sim 클록 오프셋이 있어도 차분은 정확하다.
9
- */
10
- import { test } from 'node:test'
11
- import assert from 'node:assert/strict'
12
-
13
- import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts'
14
- import { judgeTargets, judgeGroupTargets, mergeTargets, scopedTargets } from '../server/engine/kpi-target.ts'
15
-
16
- const T0 = Date.parse('2026-07-30T00:00:00Z')
17
- const at = (sec: number) => new Date(T0 + sec * 1000).toISOString()
18
-
19
- /** task.status 전이 한 줄. */
20
- const task = (taskId: string, status: string, sec: number, resourceRef?: string): KpiEvent => ({
21
- eventType: 'task.status',
22
- eventTime: at(sec),
23
- payload: { data: { taskId, status, resourceRef } }
24
- })
25
-
26
- /** 축을 가진 전이 한 줄 — kind·orderId·toNode 까지(실제 TaskStatusDelta 모양). */
27
- const taskAt = (taskId: string, status: string, sec: number, facets: Record<string, unknown>): KpiEvent => ({
28
- eventType: 'task.status',
29
- eventTime: at(sec),
30
- payload: { data: { taskId, status, ...facets } }
31
- })
32
-
33
- const order = (orderId: string, status: string, sec: number): KpiEvent => ({
34
- eventType: 'order.status',
35
- eventTime: at(sec),
36
- payload: { data: { orderId, status } }
37
- })
38
-
39
- const WINDOW = { fromMs: T0, toMs: T0 + 3600 * 1000 } // 1시간
40
-
41
- test('처리량: 창 안에 완료된 작업만 센다(진행 중은 세지 않는다)', () => {
42
- const r = foldKpi(
43
- [
44
- task('t1', 'created', 0),
45
- task('t1', 'in-progress', 10),
46
- task('t1', 'completed', 30),
47
- task('t2', 'created', 20),
48
- task('t2', 'in-progress', 25) // 완료 없음 — 진행 중
49
- ],
50
- WINDOW
51
- )
52
- assert.equal(r.throughput.tasks, 1)
53
- })
54
-
55
- test('처리량: 창 밖에 완료된 것은 세지 않는다 — 창 경계는 완료 시각으로 판정', () => {
56
- const r = foldKpi(
57
- [task('t1', 'created', -100), task('t1', 'in-progress', -90), task('t1', 'completed', -10)],
58
- WINDOW
59
- )
60
- assert.equal(r.throughput.tasks, 0)
61
- })
62
-
63
- test('리드타임·작업시간·대기시간을 각각 낸다 — 셋은 다른 질문에 답한다', () => {
64
- /* 생성 0s → 착수 10s → 완료 40s : 리드 40s, 작업 30s, 대기 10s */
65
- const r = foldKpi([task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 40)], WINDOW)
66
- assert.equal(r.leadTime.avgMs, 40000)
67
- assert.equal(r.workTime.avgMs, 30000)
68
- assert.equal(r.waitTime.avgMs, 10000)
69
- })
70
-
71
- test('분위수: p50·p90 을 낸다(작은 표본에서 과장하지 않는다)', () => {
72
- const events: KpiEvent[] = []
73
- /* 완료 소요를 1..10초로 열 건 */
74
- for (let i = 1; i <= 10; i++) {
75
- events.push(task(`t${i}`, 'created', 100 + i * 20))
76
- events.push(task(`t${i}`, 'in-progress', 100 + i * 20))
77
- events.push(task(`t${i}`, 'completed', 100 + i * 20 + i))
78
- }
79
- const r = foldKpi(events, WINDOW)
80
- assert.equal(r.workTime.count, 10)
81
- assert.equal(r.workTime.p50Ms, 6000)
82
- assert.equal(r.workTime.p90Ms, 10000)
83
- })
84
-
85
- test('시작 이벤트를 못 찾은 완료는 unpaired 로 밝힌다 — 평균에서 오류 없이 빠지지 않게', () => {
86
- const r = foldKpi([task('t1', 'completed', 30)], WINDOW)
87
- assert.equal(r.throughput.tasks, 1, '완료는 센다')
88
- assert.equal(r.leadTime.count, 0, '리드타임은 계산할 수 없다')
89
- assert.equal(r.unpaired.leadTime, 1)
90
- assert.equal(r.unpaired.workTime, 1)
91
- })
92
-
93
- test('창보다 앞선 시작도 짝지어진다 — 호출부가 앞 구간을 넉넉히 넘기면 리드타임이 살아난다', () => {
94
- const r = foldKpi([task('t1', 'created', -600), task('t1', 'in-progress', -300), task('t1', 'completed', 60)], WINDOW)
95
- assert.equal(r.leadTime.avgMs, 660000)
96
- assert.equal(r.unpaired.leadTime, 0)
97
- })
98
-
99
- test('자원 점유: 작업에 붙어 있던 시간의 합과 창 대비 비율', () => {
100
- const r = foldKpi(
101
- [
102
- task('t1', 'created', 0),
103
- task('t1', 'in-progress', 0, 'fk-1'),
104
- task('t1', 'completed', 1800, 'fk-1'), // 30분
105
- task('t2', 'created', 0),
106
- task('t2', 'in-progress', 0, 'fk-2'),
107
- task('t2', 'completed', 900, 'fk-2') // 15분
108
- ],
109
- WINDOW
110
- )
111
- assert.deepEqual(
112
- r.utilization.map(u => [u.resourceId, u.busyMs]),
113
- [
114
- ['fk-1', 1800000],
115
- ['fk-2', 900000]
116
- ],
117
- '점유가 큰 자원부터'
118
- )
119
- assert.equal(Math.round(r.utilization[0].ratio * 100), 50, '1시간 창에서 30분 = 50%')
120
- })
121
-
122
- /*
123
- * ── 오더 완료: **동의어를 받지 않는다**(2026-08-23 에 바뀐 규칙) ─────────────
124
- *
125
- * 이 시험은 예전에 「도메인 어휘를 넓게 받는다(completed/fulfilled/done)」였다 — 그것이 **동의어 목록**
126
- * 이었고, 그 방식이 낸 대가가 둘이었다.
127
- *
128
- * ① 원본의 낱말이 목록에 없으면 완료가 0 이 된다(첫 실 시스템의 `FINISHED` 가 그랬다).
129
- * ② **시뮬 트윈에서는 언제나 0 이었다** — 시뮬은 `fulfilled` 만 올리고 `status` 는 바꾸지 않는다.
130
- *
131
- * 판정은 이제 커널이 한다(`isOrderTerminal`): 1차 근거는 **양**, 낱말은 닫힌 둘(`completed`·`cancelled`).
132
- * 표준이 그 축을 갈라 두었다 — `RequestState` 는 표준도 열거하지 않고, 「됐나」는 실적에서 읽는다.
133
- */
134
- test('오더 완료: 닫힌 종결 낱말만 받는다 — 동의어는 추론하지 않는다', () => {
135
- const r = foldKpi(
136
- [order('o1', 'completed', 10), order('o2', 'cancelled', 20), order('o3', 'in-progress', 30)],
137
- WINDOW
138
- )
139
- assert.equal(r.throughput.orders, 2, 'completed · cancelled 둘')
140
- const r2 = foldKpi([order('o4', 'fulfilled', 10), order('o5', 'done', 20), order('o6', 'FINISHED', 30)], WINDOW)
141
- assert.equal(r2.throughput.orders, 0, '동의어로 보이는 낱말은 세지 않는다 — 커넥터가 옮길 일이다')
142
- assert.deepEqual(r2.orderStatusNotCounted, { fulfilled: 1, done: 1, finished: 1 }, '대신 그 사실을 말한다')
143
- })
144
-
145
- test('오더 완료: 양이 답한다 — 낱말이 진행 중이라 말해도', () => {
146
- /*
147
- * 시뮬을 되살리는 자리다. 시뮬 오더는 이행돼도 상태가 `created` 로 남으므로, 낱말로 묻는 판정은
148
- * 그 완료를 한 건도 세지 못했다.
149
- */
150
- const qty = (orderId: string, status: string, sec: number, requested: number, fulfilled: number): KpiEvent => ({
151
- eventType: 'order.status',
152
- eventTime: at(sec),
153
- payload: { data: { orderId, status, requested, fulfilled } }
154
- })
155
- const r = foldKpi([qty('o1', 'created', 10, 10, 10), qty('o2', 'created', 20, 10, 9)], WINDOW)
156
- assert.equal(r.throughput.orders, 1, '10 중 10 은 끝났고 10 중 9 는 아니다')
157
- })
158
-
159
- /*
160
- * ── 받는 오더는 처리량이 아니다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────
161
- *
162
- * 입고 · 반품 입고는 세우는 순간 `fulfilled >= requested` 다 — 받았다는 사실이 곧 그 오더의 완료이기
163
- * 때문이다. 그대로 세면 도착마다 처리량이 한 건씩 부풀고, 그 수는 화면에서 「많이 해냈다」로 읽힌다.
164
- */
165
- test('★ 입고 · 반품 입고는 처리량에서 빠진다 — 받은 것은 해낸 것이 아니다', () => {
166
- const kinded = (orderId: string, kind: string, sec: number): KpiEvent => ({
167
- eventType: 'order.status',
168
- eventTime: at(sec),
169
- payload: { data: { orderId, kind, status: 'received', requested: 10, fulfilled: 10 } }
170
- })
171
- const out = (orderId: string, sec: number): KpiEvent => ({
172
- eventType: 'order.status',
173
- eventTime: at(sec),
174
- payload: { data: { orderId, kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 } }
175
- })
176
-
177
- const r = foldKpi([kinded('i1', 'inbound', 10), kinded('r1', 'return', 20), out('o1', 30)], WINDOW)
178
-
179
- assert.equal(r.throughput.orders, 1, '나간 오더 하나만 센다')
180
- })
181
-
182
- test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => {
183
- const r = foldKpi([order('o1', 'completed', 10)], WINDOW)
184
- assert.equal(r.throughput.orders, 1)
185
- })
186
-
187
- test('오더 완료: 같은 오더를 한 번만 센다 — 폴링 원본이 다시 보내도', () => {
188
- /*
189
- * 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 있다(결함이 아니라 폴링의 성질).
190
- * 사건마다 세면 처리량이 알리지 않고 부풀려진다 — 작업 쪽은 커널의 짝맞춤이 이미 그 규율을 갖고 있었고
191
- * 오더만 없었다.
192
- */
193
- const r = foldKpi([order('o1', 'completed', 10), order('o1', 'completed', 20), order('o2', 'completed', 30)], WINDOW)
194
- assert.equal(r.throughput.orders, 2, '오더 둘 — 사건은 셋이다')
195
- })
196
-
197
- test('저널이 비면 0 이 아니라 "본 이벤트 없음" 으로 드러난다', () => {
198
- const r = foldKpi([], WINDOW)
199
- assert.equal(r.eventsSeen, 0)
200
- assert.equal(r.eventsInWindow, 0)
201
- assert.equal(r.throughput.tasks, 0)
202
- assert.equal(r.leadTime.count, 0)
203
- })
204
-
205
- test('시각이 없거나 깨진 줄은 버린다 — 0 으로 세지 않는다', () => {
206
- const r = foldKpi(
207
- [
208
- { eventType: 'task.status', payload: { data: { taskId: 'x', status: 'completed' } } },
209
- { eventType: 'task.status', eventTime: 'not-a-date', payload: { data: { taskId: 'y', status: 'completed' } } }
210
- ],
211
- WINDOW
212
- )
213
- assert.equal(r.eventsSeen, 0)
214
- assert.equal(r.throughput.tasks, 0)
215
- })
216
-
217
- test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태 차이에 걸리지 않게', () => {
218
- const r = foldKpi(
219
- [
220
- { eventType: 'task.status', eventTime: at(0), payload: { taskId: 't1', status: 'in-progress' } },
221
- { eventType: 'task.status', eventTime: at(20), payload: { taskId: 't1', status: 'completed' } }
222
- ],
223
- WINDOW
224
- )
225
- assert.equal(r.workTime.avgMs, 20000)
226
- })
227
-
228
- test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => {
229
- /* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건).
230
- * 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */
231
- const r = foldKpi(
232
- [
233
- task('t1', 'created', 0),
234
- task('t1', 'in-progress', 10, 'fk-1'),
235
- task('t1', 'completed', 40, 'fk-1'),
236
- task('t1', 'completed', 45, 'fk-1') // 재전송
237
- ],
238
- WINDOW
239
- )
240
- assert.equal(r.throughput.tasks, 1, '한 건이다')
241
- assert.equal(r.workTime.count, 1)
242
- assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준')
243
- assert.deepEqual(
244
- r.utilization.map(u => [u.resourceId, u.busyMs]),
245
- [['fk-1', 35000]],
246
- '점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)'
247
- )
248
- })
249
-
250
- test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => {
251
- const r = foldKpi(
252
- [
253
- task('t1', 'created', 0),
254
- task('t1', 'in-progress', 10),
255
- task('t1', 'in-progress', 15), // 재전송
256
- task('t1', 'completed', 40)
257
- ],
258
- WINDOW
259
- )
260
- assert.equal(r.workTime.avgMs, 30000, '처음 착수(10s) 기준')
261
- })
262
-
263
- /* ── 구간 쪼개기(추세) ─────────────────────────────────────────────────────
264
- * 화면의 추세선이 여기서 나온다. 조회는 한 번이고 창만 바꿔 다시 계산한다.
265
- */
266
- test('구간 쪼개기: N 등분해 구간별 처리량을 낸다', () => {
267
- /* 1시간 창을 4등분(15분씩). 각 구간에 한 건씩 완료되게 놓는다. */
268
- const events: KpiEvent[] = []
269
- for (let i = 0; i < 4; i++) {
270
- const sec = i * 900 + 100 // 100s, 1000s, 1900s, 2800s
271
- events.push(task(`t${i}`, 'created', sec - 30), task(`t${i}`, 'in-progress', sec - 20), task(`t${i}`, 'completed', sec))
272
- }
273
- const buckets = foldKpiBuckets(events, WINDOW, 4)
274
- assert.equal(buckets.length, 4)
275
- assert.deepEqual(buckets.map(b => b.tasks), [1, 1, 1, 1])
276
- })
277
-
278
- test('구간 쪼개기: 구간 합이 전체 창의 처리량과 같다 — 경계에서 새거나 겹치지 않는다', () => {
279
- const events: KpiEvent[] = []
280
- for (let i = 0; i < 20; i++) {
281
- const sec = 50 + i * 170
282
- events.push(task(`t${i}`, 'created', sec - 10), task(`t${i}`, 'in-progress', sec - 5), task(`t${i}`, 'completed', sec))
283
- }
284
- const whole = foldKpi(events, WINDOW)
285
- const buckets = foldKpiBuckets(events, WINDOW, 6)
286
- const sum = buckets.reduce((a, b) => a + b.tasks, 0)
287
- assert.equal(sum, whole.throughput.tasks)
288
- })
289
-
290
- test('구간 쪼개기: 마지막 구간이 창의 끝까지 덮는다 — 나눗셈 오차로 마지막 완료가 빠지지 않게', () => {
291
- /* 3등분(나누어떨어지지 않는 창) + 창의 마지막 순간에 완료 */
292
- const win = { fromMs: WINDOW.fromMs, toMs: WINDOW.fromMs + 1000 * 1000 } // 1000초
293
- const events = [task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 1000)]
294
- const buckets = foldKpiBuckets(events, win, 3)
295
- assert.equal(buckets[buckets.length - 1].toMs, win.toMs)
296
- assert.equal(buckets.reduce((a, b) => a + b.tasks, 0), 1)
297
- })
298
-
299
- test('구간 쪼개기: 1 이하면 쪼개지 않는다(추세 없음)', () => {
300
- assert.deepEqual(foldKpiBuckets([], WINDOW, 1), [])
301
- assert.deepEqual(foldKpiBuckets([], WINDOW, 0), [])
302
- })
303
-
304
- test('구간 쪼개기: 구간별 작업시간 중앙값을 낸다 — "느려지고 있는가" 를 본다', () => {
305
- const events: KpiEvent[] = [
306
- /* 앞 구간: 10초 작업 */
307
- task('a', 'created', 100), task('a', 'in-progress', 100), task('a', 'completed', 110),
308
- /* 뒤 구간: 40초 작업 */
309
- task('b', 'created', 2000), task('b', 'in-progress', 2000), task('b', 'completed', 2040)
310
- ]
311
- const buckets = foldKpiBuckets(events, WINDOW, 2)
312
- assert.equal(buckets[0].workP50Ms, 10000)
313
- assert.equal(buckets[1].workP50Ms, 40000)
314
- })
315
-
316
- /* ── 관점 축(groupBy) ───────────────────────────────────────────────────────
317
- * 왜 계산 층에 있는가: 고정된 한 렌즈만 낼 수 있으면 "구역별로는?" 이라는 질문에 화면도 AI 도 답할 수
318
- * 없다. **사실 계산이 다각도를 못 내면 AI 도 못 낸다** — 그래서 축은 폴드의 능력이어야 한다.
319
- */
320
- const three = (id: string, sec: number, facets: Record<string, unknown>): KpiEvent[] => [
321
- taskAt(id, 'created', sec - 20, facets),
322
- taskAt(id, 'in-progress', sec - 10, facets),
323
- taskAt(id, 'completed', sec, facets)
324
- ]
325
-
326
- test('축 없이는 groups 를 내지 않는다 — 묻지 않은 것을 실어 보내지 않는다', () => {
327
- const r = foldKpi(three('t1', 100, { kind: 'move' }), WINDOW)
328
- assert.equal(r.groups, undefined)
329
- })
330
-
331
- test('작업 종류별로 쪼갠다 — 전체 통계와 같은 규칙으로', () => {
332
- const events = [
333
- ...three('a', 100, { kind: 'move' }),
334
- ...three('b', 200, { kind: 'move' }),
335
- ...three('c', 300, { kind: 'pick' })
336
- ]
337
- const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
338
- assert.equal(r.groups?.by, 'taskKind')
339
- assert.deepEqual(
340
- r.groups?.items.map(g => [g.key, g.tasks]),
341
- [
342
- ['move', 2],
343
- ['pick', 1]
344
- ],
345
- '건수 큰 축부터'
346
- )
347
- const move = r.groups!.items[0]
348
- assert.equal(move.workTime.avgMs, 10000, '착수→완료 10초')
349
- assert.equal(move.leadTime.avgMs, 20000, '생성→완료 20초')
350
- assert.equal(move.busyMs, 20000, '두 건 × 10초')
351
- })
352
-
353
- test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이 새지 않는다', () => {
354
- const events = [
355
- ...three('a', 100, { kind: 'move', orderId: 'o1', toNode: 'dock-1', resourceRef: 'fk-1' }),
356
- ...three('b', 200, { kind: 'pick', orderId: 'o1', toNode: 'rack-1', resourceRef: 'fk-2' }),
357
- ...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
358
- ]
359
- const whole = foldKpi(events, WINDOW)
360
- for (const by of ['resource', 'taskKind', 'location', 'order'] as const) {
361
- const r = foldKpi(events, WINDOW, { groupBy: by })
362
- const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
363
- assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
364
- }
365
- })
366
-
367
- test('축의 값이 없던 건은 unknown 으로 모인다 — 알리지 않고 버리지 않는다', () => {
368
- const events = [...three('a', 100, { kind: 'move', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'move' })]
369
- const r = foldKpi(events, WINDOW, { groupBy: 'resource' })
370
- assert.deepEqual(r.groups?.items.map(g => g.key).sort(), ['fk-1', 'unknown'])
371
- })
372
-
373
- test('축의 값은 다른 전이에서 와도 읽는다 — 완료 이벤트에 kind 가 없어도 축이 비지 않는다', () => {
374
- /* 생성에만 kind 가 실리는 구현이 실제로 있다. 완료 줄만 보면 축이 통째로 unknown 이 된다. */
375
- const events = [
376
- taskAt('a', 'created', 80, { kind: 'putaway', orderId: 'o9' }),
377
- taskAt('a', 'in-progress', 90, {}),
378
- taskAt('a', 'completed', 100, {})
379
- ]
380
- const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
381
- assert.deepEqual(r.groups?.items.map(g => g.key), ['putaway'])
382
- const byOrder = foldKpi(events, WINDOW, { groupBy: 'order' })
383
- assert.deepEqual(byOrder.groups?.items.map(g => g.key), ['o9'])
384
- })
385
-
386
- test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값은 마지막 것이 사실', () => {
387
- const events = [
388
- taskAt('a', 'created', 80, { toNode: 'dock-1' }),
389
- taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
390
- taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
391
- ]
392
- const r = foldKpi(events, WINDOW, { groupBy: 'location' })
393
- assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
394
- })
395
-
396
- test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없으면 노드 id 로 대체하지 않는다', () => {
397
- const events = [...three('a', 100, { toNode: 'rack-1' }), ...three('b', 200, { toNode: 'rack-2' })]
398
- const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
399
- assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
400
-
401
- const mapped = foldKpi(events, WINDOW, { groupBy: 'area', locationArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
402
- assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
403
- })
404
-
405
- test('축이 많으면 잘라내고 잘린 축의 수와 그 안의 건수를 함께 알린다 — 들어온 것이 없는 절단 금지', () => {
406
- /* 축마다 건수를 다르게 둔다: 축의 수만 알리면 "그 외 20개" 가 20건인지 60건인지 알 수 없다. */
407
- const events: KpiEvent[] = []
408
- let t = 0
409
- for (let i = 0; i < 25; i++) {
410
- const n = i < 5 ? 3 : 1 // 앞 5개 축은 3건씩, 나머지 20개 축은 1건씩
411
- for (let k = 0; k < n; k++) events.push(...three(`t${t++}`, 100 + t * 10, { orderId: `o${i}` }))
412
- }
413
- const r = foldKpi(events, WINDOW, { groupBy: 'order', groupLimit: 5 })
414
- assert.equal(r.groups?.items.length, 5)
415
- assert.equal(r.groups?.truncated, 20, '잘린 축의 수')
416
- assert.equal(r.groups?.truncatedTasks, 20, '잘린 축들이 안고 있던 건수')
417
- const whole = foldKpi(events, WINDOW)
418
- assert.equal(
419
- r.groups!.items.reduce((a, g) => a + g.tasks, 0) + r.groups!.truncatedTasks!,
420
- whole.throughput.tasks,
421
- '보여준 건수 + 잘린 건수 = 전체(축의 수가 아니라 건수로 맞는다)'
422
- )
423
- })
424
-
425
- test('짝을 못 찾은 건도 축에서 센다 — 건수는 세고 소요만 비운다', () => {
426
- const r = foldKpi([taskAt('a', 'completed', 100, { kind: 'move' })], WINDOW, { groupBy: 'taskKind' })
427
- const g = r.groups!.items[0]
428
- assert.equal(g.tasks, 1)
429
- assert.equal(g.workTime.count, 0)
430
- assert.equal(g.busyMs, 0)
431
- })
432
-
433
- /* ── 시간 비교 ──────────────────────────────────────────────────────────────
434
- * "29초" 만으로는 좋아졌는지 나빠졌는지 알 수 없다. 창을 옮겨 한 번 더 계산 규칙을 고정한다.
435
- */
436
- test('직전 구간: 같은 길이로 바로 앞 — 경계가 맞물린다(겹치거나 벌어지지 않게)', () => {
437
- const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous')
438
- assert.equal(w.toMs, WINDOW.fromMs, '앞 구간의 끝 = 현재 구간의 시작')
439
- assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs, '길이가 같다')
440
- })
441
-
442
- test('어제 같은 시간: 정확히 24시간 전 같은 자리', () => {
443
- const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'yesterday')
444
- assert.equal(WINDOW.fromMs - w.fromMs, 24 * 60 * 60 * 1000)
445
- assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs)
446
- })
447
-
448
- test('중앙값 차이: 현재 − 비교구간', () => {
449
- const now = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
450
- const prev = { count: 5, p50Ms: 30000, p90Ms: 0, avgMs: 0 }
451
- assert.equal(deltaP50(now, prev), 10000, '10초 느려졌다')
452
- assert.equal(deltaP50(prev, now), -10000, '부호로 방향을 말한다(좋고 나쁨은 판단하지 않는다)')
453
- })
454
-
455
- test('중앙값 차이: 한쪽이 결측이면 차이는 없다 — 0 으로 내면 "변화 없음" 이라는 거짓이 된다', () => {
456
- const measured = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
457
- const missing = { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
458
- assert.equal(deltaP50(measured, missing), undefined)
459
- assert.equal(deltaP50(missing, measured), undefined)
460
- })
461
-
462
- test('비교: 옮긴 창으로 계산하면 앞 구간의 성과가 나온다 — 같은 폴드, 창만 다르다', () => {
463
- /* 현재 구간에 2건, 직전 구간에 1건. */
464
- const events = [
465
- ...three('now-1', 100, { kind: 'move' }),
466
- ...three('now-2', 200, { kind: 'move' }),
467
- ...three('prev-1', -1800, { kind: 'move' })
468
- ]
469
- const now = foldKpi(events, WINDOW)
470
- const prev = foldKpi(events, shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous'))
471
- assert.equal(now.throughput.tasks, 2)
472
- assert.equal(prev.throughput.tasks, 1)
473
- assert.equal(now.throughput.tasks - prev.throughput.tasks, 1, '처리량이 1건 늘었다')
474
- })
475
-
476
- test('창 앞 구간만 읽었으면 "측정된 기록 있음" 이 아니다 — lookback 을 성과로 착각하지 않게', () => {
477
- /* 호출부는 창보다 앞까지 읽어 넘긴다(완료 작업의 시작이 창 밖일 수 있어서). 그 앞 구간 기록만
478
- * 있는데 eventsSeen 으로 판단하면 "0건 처리" 라고 말하게 된다 — 실제로 가능했던 오류다. */
479
- const before = [task('t1', 'created', -1200), task('t1', 'in-progress', -1100), task('t1', 'completed', -1000)]
480
- const r = foldKpi(before, WINDOW)
481
- assert.equal(r.eventsSeen, 3, '넘겨받은 것은 3줄')
482
- assert.equal(r.eventsInWindow, 0, '창 안에는 아무것도 없다 → "기록 없음"')
483
- assert.equal(r.throughput.tasks, 0)
484
- })
485
-
486
- test('창 안 기록만 센다 — 앞 구간이 섞여도 창 안 개수는 정확하다', () => {
487
- const events = [
488
- task('old', 'completed', -500), // 창 밖(앞)
489
- ...three('in-1', 100, { kind: 'move' }), // 창 안 3줄
490
- task('late', 'completed', 4000) // 창 밖(뒤)
491
- ]
492
- const r = foldKpi(events, WINDOW)
493
- assert.equal(r.eventsSeen, 5)
494
- assert.equal(r.eventsInWindow, 3)
495
- })
496
-
497
- /* ── 관점 × 비교 교차 ───────────────────────────────────────────────────────
498
- * 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. 그 교차가 운영에서 가장
499
- * 값 있는 질문이다.
500
- */
501
- const g = (key: string, tasks: number, workMs = 10000) => {
502
- const events = []
503
- for (let i = 0; i < tasks; i++) {
504
- const sec = 100 + i * 30
505
- events.push(
506
- taskAt(`${key}-${i}`, 'created', sec - 20, { kind: key }),
507
- taskAt(`${key}-${i}`, 'in-progress', sec, { kind: key }),
508
- taskAt(`${key}-${i}`, 'completed', sec + workMs / 1000, { kind: key })
509
- )
510
- }
511
- return foldKpi(events, WINDOW, { groupBy: 'taskKind' }).groups!.items[0]
512
- }
513
-
514
- test('교차: 두 구간에 모두 있는 축은 delta 를 낸다', () => {
515
- const c = crossGroups([g('pick', 10, 20000)], [g('pick', 7, 10000)])
516
- const it = c.items[0]
517
- assert.equal(it.prevTasks, 7)
518
- assert.equal(it.deltaTasks, 3)
519
- assert.equal(it.deltaWorkP50Ms, 10000, '작업시간이 10초 늘었다')
520
- assert.deepEqual(c.disappeared, [])
521
- })
522
-
523
- test('교차: 지금만 있는 축은 isNew — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다', () => {
524
- const c = crossGroups([g('putaway', 5)], [g('pick', 5)])
525
- const nw = c.items.find(x => x.key === 'putaway')!
526
- assert.equal(nw.isNew, true)
527
- assert.equal(nw.deltaTasks, undefined)
528
- assert.equal(nw.prevTasks, undefined)
529
- })
530
-
531
- test('교차: 사라진 축을 따로 알린다 — 멈춘 구역이 표에서 오류 없이 빠지지 않게', () => {
532
- const c = crossGroups([g('pick', 5)], [g('pick', 5), g('putaway', 12)])
533
- assert.deepEqual(c.disappeared, [{ key: 'putaway', tasks: 12 }])
534
- })
535
-
536
- test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어든 것을 섞지 않는다', () => {
537
- const c = crossGroups([g('pick', 3), g('putaway', 4)], [])
538
- assert.equal(c.items.every(x => x.isNew === true), true)
539
- assert.deepEqual(c.disappeared, [])
540
- })
541
-
542
- /* ── 교대 축 ─────────────────────────────────────────────────────────────────
543
- *
544
- * 3교대처럼 하루를 끊김 없이 덮는 현장에서는 캘린더가 가용 시간을 줄이지 않는다. 그러면 교대 선언이
545
- * 무엇을 주는가: **"언제" 를 묻는 축**이다. "어느 교대가 느린가" 는 3교대 공장에서 가장 먼저 나오는
546
- * 질문인데, 그 축이 없으면 답이 없었다.
547
- *
548
- * **저널에는 교대가 없다 — 계산한다.** 이벤트에 교대 이름을 찍어 두면 나중에 교대 시간을 고쳤을 때
549
- * **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 완료 시각 + 현장의 시각 기준 + 교대 선언에서 낸다.
550
- */
551
- const SHIFTS_3 = [
552
- { id: 'A', fromTime: '06:00', toTime: '14:00' },
553
- { id: 'B', fromTime: '14:00', toTime: '22:00' },
554
- { id: 'C', fromTime: '22:00', toTime: '06:00', toDayOffset: 1 as const }
555
- ]
556
-
557
- /** 하루치 창 — 세 교대가 모두 들어오게. */
558
- const DAY = { fromMs: T0, toMs: T0 + 24 * 3600 * 1000 }
559
-
560
- /** `hour` 시(UTC)에 완료되는 작업 한 건. */
561
- const doneAtHour = (id: string, hour: number): KpiEvent[] => [
562
- task(id, 'created', hour * 3600 - 60),
563
- task(id, 'in-progress', hour * 3600 - 30),
564
- task(id, 'completed', hour * 3600)
565
- ]
566
-
567
- test('교대별로 쪼갠다 — 완료 시각에서 계산한다(저널에 없는 축)', () => {
568
- const r = foldKpi(
569
- [...doneAtHour('a1', 7), ...doneAtHour('a2', 9), ...doneAtHour('b1', 15), ...doneAtHour('c1', 23)],
570
- DAY,
571
- { groupBy: 'shift', shifts: SHIFTS_3 }
572
- )
573
- const by = Object.fromEntries((r.groups?.items ?? []).map(g => [g.key, g.tasks]))
574
- assert.deepEqual(by, { A: 2, B: 1, C: 1 })
575
- assert.equal(r.groups?.items.reduce((s, g) => s + g.tasks, 0), r.throughput.tasks, '축의 합이 전체와 같다')
576
- })
577
-
578
- test('현장의 시각 기준으로 읽는다 — UTC 로 알리지 않고 가정하지 않는다', () => {
579
- const events = [...doneAtHour('t1', 20)] // 절대 20시
580
- const utc = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 })
581
- const local = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3, utcOffsetMinutes: -420 })
582
- assert.equal(utc.groups?.items[0].key, 'B', 'UTC 20시는 B 교대')
583
- assert.equal(local.groups?.items[0].key, 'A', 'Rosarito(UTC−7) 기준으로는 13시 — A 교대다')
584
- })
585
-
586
- test('교대를 선언하지 않은 현장은 unknown — 없는 구분을 지어내지 않는다', () => {
587
- const r = foldKpi([...doneAtHour('t1', 7)], DAY, { groupBy: 'shift' })
588
- assert.deepEqual((r.groups?.items ?? []).map(g => g.key), ['unknown'])
589
- })
590
-
591
- test('교대 정의를 고치면 과거가 다시 세어진다 — 저널에 찍어 뒀다면 굳었을 것', () => {
592
- const events = [...doneAtHour('t1', 7)]
593
- const twoShift = [
594
- { id: 'DAY', fromTime: '07:00', toTime: '19:00' },
595
- { id: 'NIGHT', fromTime: '19:00', toTime: '07:00', toDayOffset: 1 as const }
596
- ]
597
- assert.equal(foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }).groups?.items[0].key, 'A')
598
- assert.equal(
599
- foldKpi(events, DAY, { groupBy: 'shift', shifts: twoShift }).groups?.items[0].key,
600
- 'DAY',
601
- '같은 저널, 다른 교대 정의 → 다시 센다'
602
- )
603
- })
604
-
605
- /* ── 목표 대비 판정 ───────────────────────────────────────────────────────────
606
- *
607
- * 화면과 AI 는 "처리량 345건" 을 정확히 말하지만 **그것이 좋은지 나쁜지는 말할 수 없었다.** 근거가 앞
608
- * 구간 비교뿐이라 "어제보다 나아졌다" 가 한계이고, 어제도 나빴으면 쓸모가 없다.
609
- *
610
- * 판정은 **사실 계산과 섞지 않는다** — 폴드는 목표를 모르고, 판정은 순수 함수로 따로 있다.
611
- */
612
- const kpiOf = (events: KpiEvent[]) => foldKpi(events, DAY)
613
-
614
- test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없다', () => {
615
- const kpi = kpiOf([...doneAtHour('t1', 7), ...doneAtHour('t2', 8)])
616
- const [more] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 2 }])
617
- assert.equal(more.direction, 'higher')
618
- assert.equal(more.met, true, '처리량은 목표 이상이면 만족')
619
-
620
- const [faster] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 60_000 }])
621
- assert.equal(faster.direction, 'lower', '시간은 낮을수록 좋다 — 입력이 아니라 지표가 안다')
622
- assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초')
623
- })
624
-
625
- test('미달을 미달로 판정한다', () => {
626
- const kpi = kpiOf([...doneAtHour('t1', 7)])
627
- const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }])
628
- assert.equal(v.met, false)
629
- assert.equal(v.actual, 1)
630
- assert.equal(v.ratio, 0.2, '비율은 항상 "1 이상이면 좋다" 로 정규화된다')
631
- })
632
-
633
- test('비율은 방향이 달라도 같은 뜻이다 — 한 화면에서 비교할 수 있게', () => {
634
- const kpi = kpiOf([...doneAtHour('t1', 7)])
635
- const [slow] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 15_000 }]) // 실제 30초, 목표 15초
636
- assert.equal(slow.met, false)
637
- assert.equal(slow.ratio, 0.5, '두 배 느리면 0.5 — 처리량이 절반일 때와 같은 숫자')
638
- })
639
-
640
- test('목표가 없으면 판정하지 않는다', () => {
641
- assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), []), [])
642
- assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), [])
643
- })
644
-
645
- test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 판정이 반대로 뒤집힌다', () => {
646
- const empty = foldKpi([], DAY)
647
- /* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면
648
- 시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 —
649
- 미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). */
650
- assert.deepEqual(judgeTargets(empty, [{ metric: 'workTime.p50Ms', target: 15_000 }]), [])
651
- assert.deepEqual(judgeTargets(empty, [{ metric: 'throughput.tasks', target: 10 }]), [], '기록 없음 ≠ 처리량 0')
652
- })
653
-
654
- test('모르는 지표 이름은 짐작해 매핑하지 않는다', () => {
655
- assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), [{ metric: 'cycleTime.p50' as never, target: 1 }]), [])
656
- })
657
-
658
- /* ── 목표의 범위 (공간 vs 인스턴스) ──────────────────────────────────────────
659
- *
660
- * 이 규칙은 원래 DB 접근 뒤에 숨어 있어 **화면을 눌러 봐야만** 확인할 수 있었다. 로그인 뒤 화면을
661
- * 자동으로 검증할 방법이 없는 프로젝트라 그런 규칙은 알리지 않고 미검증으로 쌓인다 — 그래서 밖으로 뺐다.
662
- */
663
- const row = (metric: string, target: number, scope: { spaceId?: string; instanceId?: string }) => ({
664
- metric,
665
- target,
666
- spaceId: scope.spaceId ?? null,
667
- instanceId: scope.instanceId ?? null
668
- })
669
-
670
- test('공간 목표가 그 공간의 모든 트윈에 걸린다', () => {
671
- const got = mergeTargets([row('throughput.tasks', 100, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: ['t1', 't2'] })
672
- assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 100 }])
673
- })
674
-
675
- test('인스턴스 목표가 공간 목표를 덮는다 — 예외를 표현할 수 있어야 한다', () => {
676
- const got = mergeTargets(
677
- [row('throughput.tasks', 100, { spaceId: 'site-a' }), row('throughput.tasks', 40, { instanceId: 't2' })],
678
- { spaceId: 'site-a', instanceIds: ['t2'] }
679
- )
680
- assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 40 }], '"이 라인만 다르다" 를 말할 수 있어야 한다')
681
- })
682
-
683
- test('다른 공간·다른 인스턴스의 목표는 새어 들어오지 않는다', () => {
684
- const got = mergeTargets(
685
- [row('throughput.tasks', 999, { spaceId: 'site-b' }), row('workTime.p50Ms', 999, { instanceId: 'other' })],
686
- { spaceId: 'site-a', instanceIds: ['t1'] }
687
- )
688
- assert.deepEqual(got, [])
689
- })
690
-
691
- test('모르는 지표 이름·숫자 아닌 값은 목표로 세지 않는다', () => {
692
- const got = mergeTargets(
693
- [row('cycleTime.p50', 10, { spaceId: 'site-a' }), row('throughput.tasks', Number.NaN, { spaceId: 'site-a' })],
694
- { spaceId: 'site-a', instanceIds: [] }
695
- )
696
- assert.deepEqual(got, [], '평가할 수 없는 목표는 "목표 없음" 으로 읽혀 갭을 감춘다 — 아예 세지 않는다')
697
- })
698
-
699
- test('공간을 안 고르고 트윈 하나만 볼 때도 그 인스턴스 목표는 걸린다', () => {
700
- const got = mergeTargets([row('waitTime.p50Ms', 5000, { instanceId: 't1' })], { instanceIds: ['t1'] })
701
- assert.deepEqual(got, [{ metric: 'waitTime.p50Ms', target: 5000 }])
702
- })
703
-
704
- /* ── 축별 목표 ───────────────────────────────────────────────────────────────
705
- *
706
- * 구역·교대마다 목표가 다를 수 있다("이 라인만 40건"). 그런데 축 목표는 **전체 목표를 덮지 않는다** —
707
- * 다른 질문이기 때문이다: "현장 전체 100건" 과 "용접 구역 30건" 은 동시에 참일 수 있다. 덮게 만들면
708
- * 축 하나에 목표를 거는 순간 전체 판정이 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
709
- */
710
- const arow = (metric: string, target: number, extra: Record<string, unknown> = {}) => ({
711
- metric,
712
- target,
713
- spaceId: 'site-a',
714
- instanceId: null,
715
- axis: null,
716
- axisKey: null,
717
- ...extra
718
- })
719
-
720
- test('축 목표와 전체 목표가 함께 산다 — 덮지 않는다', () => {
721
- const got = scopedTargets(
722
- [arow('throughput.tasks', 100), arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })],
723
- { spaceId: 'site-a', instanceIds: [], axis: 'area' }
724
- )
725
- assert.deepEqual(got.overall, [{ metric: 'throughput.tasks', target: 100 }], '전체 목표는 그대로 있다')
726
- assert.deepEqual(got.byAxisKey.weld, [{ metric: 'throughput.tasks', target: 30 }])
727
- })
728
-
729
- test('지금 보는 축의 목표만 온다 — 다른 축의 목표가 섞이지 않는다', () => {
730
- const got = scopedTargets(
731
- [arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' }), arow('throughput.tasks', 50, { axis: 'shift', axisKey: 'A' })],
732
- { spaceId: 'site-a', instanceIds: [], axis: 'shift' }
733
- )
734
- assert.deepEqual(Object.keys(got.byAxisKey), ['A'])
735
- })
736
-
737
- test('축을 안 보고 있으면 축 목표는 오지 않는다 — 전체만', () => {
738
- const got = scopedTargets([arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], {
739
- spaceId: 'site-a',
740
- instanceIds: []
741
- })
742
- assert.deepEqual(got.byAxisKey, {})
743
- assert.deepEqual(got.overall, [])
744
- })
745
-
746
- test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않는다', () => {
747
- const group = {
748
- key: 'weld',
749
- tasks: 12,
750
- leadTime: { count: 12, p50Ms: 90_000 },
751
- workTime: { count: 12, p50Ms: 30_000 },
752
- waitTime: { count: 0, p50Ms: 0 }
753
- }
754
- const v = judgeGroupTargets(group, [
755
- { metric: 'throughput.tasks', target: 10 },
756
- { metric: 'workTime.p50Ms', target: 20_000 },
757
- { metric: 'waitTime.p50Ms', target: 5_000 },
758
- { metric: 'utilization.ratio', target: 0.8 }
759
- ])
760
- assert.deepEqual(
761
- v.map(x => [x.metric, x.met]),
762
- [
763
- ['throughput.tasks', true],
764
- ['workTime.p50Ms', false]
765
- ],
766
- '대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함'
767
- )
768
- })
769
-
770
- /*
771
- * ── 에너지 원단위 (§10 7단계) ───────────────────────────────────────────────
772
- *
773
- * 원단위는 분자(전력량)와 분모(산출)를 **같은 창**에서 나눈 값이다. 그래서 별도 질의가 아니라 이 폴드
774
- * 안에 있다 — 창이 하나뿐이면 어긋날 수 없다.
775
- *
776
- * 분자는 저널의 **구간 마감 사건**에서 온다(커널 상태가 아니라 저널이므로 멈춘 트윈·과거 구간에도
777
- * 답한다). 그 값이 파생임을 `basis` 가 말하고, 못 센 구간 수를 함께 낸다.
778
- */
779
- const T = Date.parse('2026-08-14T00:00:00.000Z')
780
- const q = 15 * 60_000
781
- const win = (startMs: number, endMs: number, meanKW?: number) => ({
782
- eventType: 'energy.demand.window',
783
- eventTime: new Date(endMs).toISOString(),
784
- payload: { data: { startMs, endMs, ...(meanKW !== undefined ? { meanKW } : {}) } }
785
- })
786
- const orderDone = (atMs: number) => ({
787
- eventType: 'order.status',
788
- eventTime: new Date(atMs).toISOString(),
789
- payload: { data: { status: 'completed' } }
790
- })
791
-
792
- test('에너지 사건이 없으면 에너지 블록을 내지 않는다 — 없는 것을 만들지 않는다', () => {
793
- const r = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
794
- assert.equal(r.energy, undefined)
795
- })
796
-
797
- test('구간 마감에서 전력량과 대당 원단위를 낸다 — 같은 창이므로 어긋날 수 없다', () => {
798
- const r = foldKpi(
799
- [win(T, T + q, 400), win(T + q, T + 2 * q, 400), orderDone(T + 60_000), orderDone(T + q + 60_000)],
800
- { fromMs: T, toMs: T + 2 * q }
801
- )
802
- assert.equal(r.energy?.kWh, 200, '400kW × 0.25h × 2구간')
803
- assert.equal(r.energy?.basis, 'mean-kw', '파생임을 밝힌다')
804
- assert.equal(r.energy?.counted, 2)
805
- assert.equal(r.energy?.intensity.value, 100, '200kWh ÷ 완료 2건')
806
- assert.equal(r.energy?.intensity.unit, 'kWh/unit')
807
- })
808
-
809
- test('그 창에 완료가 없으면 원단위는 정의되지 않는다 — 전력량은 그대로 낸다', () => {
810
- const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
811
- assert.equal(r.energy?.kWh, 100, '전력량은 사실이다')
812
- assert.equal(r.energy?.intensity.value, null)
813
- assert.equal(r.energy?.intensity.reason, 'zero-denominator', '무한으로 내지 않는다')
814
- })
815
-
816
- test('계측이 끊긴 구간은 세지 않고 몇 개를 못 셌는지 말한다', () => {
817
- const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q), orderDone(T + 60_000)], { fromMs: T, toMs: T + 2 * q })
818
- assert.equal(r.energy?.counted, 1)
819
- assert.equal(r.energy?.skipped, 1, '알리지 않고 빼면 이 합이 그 창의 전부인 줄 읽는다')
820
- })
821
-
822
- test('창에 걸친 구간은 세지 않는다 — 부분을 비례로 자르면 추정이 하나 더 생긴다', () => {
823
- const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q, 400)], { fromMs: T, toMs: T + q + 60_000 })
824
- assert.equal(r.energy?.counted, 1)
825
- assert.equal(r.energy?.kWh, 100)
826
- })
827
-
828
- /*
829
- * ── 창의 최대수요 (2026-08-17) ──────────────────────────────────────────────
830
- *
831
- * 전력량은 요금의 사용량 부분이고, 최대수요는 **기본요금과 계약 위반**을 정한다. 평균으로 갈음하면
832
- * 계약을 넘긴 창이 「안 넘겼다」로 보고된다 — 15분 평균 400kW 인 창의 최대가 1,200kW 일 수 있다.
833
- */
834
- const winMax = (startMs: number, endMs: number, meanKW: number, maxKW: number) => ({
835
- eventType: 'energy.demand.window',
836
- eventTime: new Date(endMs).toISOString(),
837
- payload: { data: { startMs, endMs, meanKW, maxKW } }
838
- })
839
-
840
- test('창의 최대수요는 구간 최대들 중 가장 큰 값이다 — 평균으로 갈음하지 않는다', () => {
841
- const r = foldKpi([winMax(T, T + q, 400, 620), winMax(T + q, T + 2 * q, 400, 1180)], { fromMs: T, toMs: T + 2 * q })
842
- assert.equal(r.energy?.kWh, 200)
843
- assert.equal(r.energy?.peakKW, 1180)
844
- assert.equal(r.energy?.peakStartMs, T + q, '언제였는지도 답할 수 있어야 한다')
845
- })
846
-
847
- test('구간이 최대를 싣지 않으면 최대수요는 없다 — 0 도 평균도 아니다', () => {
848
- const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
849
- assert.equal(r.energy?.kWh, 100)
850
- assert.equal(r.energy?.peakKW, undefined)
851
- })
852
-
853
- /*
854
- * ── 에너지 목표 (2026-08-17) ────────────────────────────────────────────────
855
- *
856
- * 값은 폴드가 내고 있었는데 목표를 걸 수 있는 어휘에 없었다 — 그래서 화면은 「1,240kWh」를 보이면서도
857
- * 그것이 계약 안쪽인지 말하지 못했다. 방향은 셋 다 「낮을수록 좋다」로 못 박혀 있다.
858
- */
859
- test('최대수요 목표 — 계약 상한을 넘겼는지 판정한다', () => {
860
- const kpi = foldKpi([winMax(T, T + q, 400, 1180)], { fromMs: T, toMs: T + q })
861
- const [v] = judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }])
862
- assert.equal(v.direction, 'lower', '많이 쓸수록 좋은 에너지 목표는 없다')
863
- assert.equal(v.actual, 1180)
864
- assert.equal(v.met, false, '계약 800kW 를 넘겼다')
865
- })
866
-
867
- test('전력량·원단위에도 목표를 건다', () => {
868
- const kpi = foldKpi([win(T, T + q, 400), orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
869
- const [kwh] = judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }])
870
- assert.equal(kwh.actual, 100)
871
- assert.equal(kwh.met, true)
872
- const [per] = judgeTargets(kpi, [{ metric: 'energy.intensity', target: 80 }])
873
- assert.equal(per.actual, 100, '100kWh ÷ 완료 1건')
874
- assert.equal(per.met, false)
875
- })
876
-
877
- test('에너지를 못 잰 창은 판정하지 않는다 — 0 으로 읽으면 안 쓴 것이 만점이 된다', () => {
878
- const kpi = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
879
- assert.equal(judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }]).length, 0)
880
- assert.equal(judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }]).length, 0)
881
- /* 원단위는 분모가 없어 `null` 로 온다 — 그것도 판정 대상이 아니다. */
882
- const noOrders = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
883
- assert.equal(noOrders.energy?.intensity.value, null)
884
- assert.equal(judgeTargets(noOrders, [{ metric: 'energy.intensity', target: 80 }]).length, 0)
885
- })
886
-
887
- /*
888
- * ── 분기별 몫 (2026-08-17) ──────────────────────────────────────────────────
889
- *
890
- * 저널의 에너지 사실은 **현장 총량 한 줄**이다. 그래서 자리별 몫은 잰 것이 아니라 **나눈 것**이고,
891
- * 그 사실을 값과 함께 내야 한다. 이 검사가 지키는 것: 합 보존 · 근거 표시 · 모르는 자리를 지어내지
892
- * 않기 · 선언이 없으면 분해하지 않기.
893
- */
894
- const runs = (taskId: string, resource: string, fromSec: number, toSec: number) => [
895
- { eventType: 'task.status', eventTime: new Date(T + fromSec * 1000).toISOString(), payload: { data: { taskId, status: 'in-progress', resourceRef: resource } } },
896
- { eventType: 'task.status', eventTime: new Date(T + toSec * 1000).toISOString(), payload: { data: { taskId, status: 'completed', resourceRef: resource } } }
897
- ]
898
-
899
- test('분기별 몫 — 가동시간에 비례해 나누고, 합은 총량을 지킨다', () => {
900
- /* press 300초 · weld 100초 → 3:1. 총량 100kWh. */
901
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
902
- const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
903
- assert.equal(r.energy?.kWh, 100)
904
- const by = r.energy?.byFeeder ?? []
905
- assert.equal(by.length, 2)
906
- const press = by.find(x => x.key === 'feeder-press')!
907
- const weld = by.find(x => x.key === 'feeder-weld')!
908
- assert.ok(Math.abs(press.kWh - 75) < 1e-6, `press ${press.kWh}`)
909
- assert.ok(Math.abs(weld.kWh - 25) < 1e-6, `weld ${weld.kWh}`)
910
- /* 합 보존 — 이것이 깨지면 어딘가에서 전기가 사라지거나 생겨난 것이고, 그 위 모든 원단위가 거짓이다. */
911
- const sum = by.reduce((a, x) => a + x.kWh, 0) + (r.energy?.unattributedKWh ?? 0)
912
- assert.ok(Math.abs(sum - 100) < 1e-6, `합 ${sum}`)
913
- })
914
-
915
- test('몫에는 근거가 붙는다 — 여럿을 나눈 것이면 배분이라고 말한다', () => {
916
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
917
- const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
918
- const row = r.energy!.byFeeder![0]
919
- assert.equal(row.basis, 'apportioned')
920
- assert.equal(row.weightKind, 'runtimeMs')
921
- assert.ok(row.weightShare > 0)
922
- })
923
-
924
- /*
925
- * 선언된 부하가 **하나뿐인** 현장 — 커널은 「그 계량기는 그것만 잰다」고 읽는다(모델이 그렇게 말했다).
926
- * 우리가 그것을 배분으로 낙인찍지 않는다: 처음에는 하드코딩으로 그렇게 했고, 그건 커널이 판정한 것을
927
- * 화면에서 바꿔 말하는 짓이었다. 반대로 「돌아간 자원만 덮는다」고 거짓 선언을 넘기면 여럿인 현장에서도
928
- * 이 경로로 빠져 배분이 측정으로 보고된다 — 그것이 실제로 처음의 결함이었다.
929
- */
930
- test('선언된 부하가 하나면 커널의 판정을 그대로 옮긴다', () => {
931
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
932
- const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
933
- assert.equal(r.energy!.byFeeder![0].basis, 'measured')
934
- })
935
-
936
- test('돌지 않은 설비는 0kWh 를 받지 않고, 몇 대가 빠졌는지 센다', () => {
937
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
938
- const r = foldKpi(events, { fromMs: T, toMs: T + q }, {
939
- resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld', 'paint-1': 'feeder-paint' }
940
- })
941
- const keys = (r.energy!.byFeeder ?? []).map(x => x.key)
942
- assert.deepEqual(keys, ['feeder-press'], '가동 기록이 없는 자리는 행을 만들지 않는다(0 을 지어내지 않는다)')
943
- assert.equal(r.energy!.excludedResources, 2, '빠진 설비 수를 밝힌다')
944
- })
945
-
946
- test('자리를 모르는 자원은 unknown 으로 모인다 — 빼지도, 아무 분기에 넣지도 않는다', () => {
947
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 200), ...runs('t2', 'ghost-1', 0, 200)]
948
- const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
949
- const by = r.energy!.byFeeder!
950
- assert.ok(by.some(x => x.key === 'unknown'), '모르는 자리를 지어내지 않는다')
951
- const sum = by.reduce((a, x) => a + x.kWh, 0)
952
- assert.ok(Math.abs(sum - 100) < 1e-6, '모르는 몫까지 합해 총량을 지킨다')
953
- })
954
-
955
- test('선언이 없으면 분해하지 않는다 — 자리를 모르는 채 나눈 숫자는 아무 것의 몫도 아니다', () => {
956
- const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
957
- const r = foldKpi(events, { fromMs: T, toMs: T + q })
958
- assert.equal(r.energy?.byFeeder, undefined)
959
- assert.equal(r.energy?.kWh, 100, '총량은 그대로 낸다')
960
- })
961
-
962
- test('그 창에 아무도 돌지 않았으면 나누지 않고 이유를 낸다', () => {
963
- /* 부하가 둘 이상 선언된 현장 — 나눌 대상은 있는데 몫이 없다. */
964
- const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, {
965
- resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' }
966
- })
967
- assert.equal(r.energy?.byFeeder, undefined, '가동 기록이 없으면 나눌 근거가 없다')
968
- assert.equal(r.energy?.unattributedReason, 'no-weights')
969
- assert.equal(r.energy?.unattributedKWh, 100, '나누지 못한 양은 그대로 남는다(0 으로 만들지 않는다)')
970
- assert.equal(r.energy?.kWh, 100)
971
- })
972
-
973
- /*
974
- * ── 금액 (2026-08-17) ───────────────────────────────────────────────────────
975
- *
976
- * 트윈은 「최대수요 328kW」까지 말하고 멈춰 있었다. 피크를 깎는 일이 돈이 되는 이유가 요금의 구조에
977
- * 있으므로(사용량 + 기본요금) 단가를 선언하면 금액이 나온다. 계산은 커널이 하고 폴드는 나른다 —
978
- * 이 검사는 **같은 창의 값에서 나오는지**와 **단가가 없을 때 지어내지 않는지**를 본다.
979
- */
980
- test('단가를 선언하면 이 창의 금액이 나온다 — 전력량과 최대수요 각각에서', () => {
981
- const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q }, {
982
- tariff: { energyChargePerKWh: 0.1, demandChargePerKW: 20, currency: 'USD' }
983
- })
984
- assert.equal(r.energy?.kWh, 100)
985
- assert.equal(r.energy?.peakKW, 1000)
986
- const c: any = r.energy?.cost
987
- assert.equal(c.energyCharge, 10, '100kWh × 0.1')
988
- assert.equal(c.demandCharge, 20_000, '1,000kW × 20')
989
- assert.equal(c.currency, 'USD')
990
- assert.equal(c.demandBasis, 'window-peak-as-period-peak', '기본요금의 조건이 값과 함께 온다')
991
- })
992
-
993
- test('단가를 선언하지 않으면 금액을 만들지 않는다', () => {
994
- const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q })
995
- assert.equal(r.energy?.cost, undefined)
996
- })
997
-
998
- test('최대수요를 못 잰 창은 기본요금을 지어내지 않는다', () => {
999
- const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20, energyChargePerKWh: 0.1 } })
1000
- const c: any = r.energy?.cost
1001
- assert.equal(c.energyCharge, 10)
1002
- assert.equal(c.demandCharge, undefined)
1003
- })
1004
-
1005
- test('에너지 사실이 없는 창에는 금액도 없다 — 0 원이라고 말하지 않는다', () => {
1006
- const r = foldKpi([orderDone(T + 1000)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20 } })
1007
- assert.equal(r.energy, undefined)
1008
- })
1009
-
1010
- /*
1011
- * ── 깎인 양 (2026-08-17) ────────────────────────────────────────────────────
1012
- *
1013
- * 요금은 계통 수요로 매겨지고, 그 수요를 만든 것은 설비 부하다. 두 수의 간격이 곧 태양광·배터리가
1014
- * 덜어낸 몫이다 — 그것이 피크 셰이빙의 값이다. **같은 구간의 두 수**여야 차이가 뜻을 갖는다.
1015
- */
1016
- const winNet = (startMs: number, endMs: number, meanKW: number, maxKW: number, grossMaxKW?: number) => ({
1017
- eventType: 'energy.demand.window',
1018
- eventTime: new Date(endMs).toISOString(),
1019
- payload: { data: { startMs, endMs, meanKW, maxKW, ...(grossMaxKW !== undefined ? { grossMaxKW } : {}) } }
1020
- })
1021
-
1022
- test('피크 구간의 총부하와 깎인 양을 낸다', () => {
1023
- const r = foldKpi([winNet(T, T + q, 300, 400, 600), winNet(T + q, T + 2 * q, 500, 900, 1200)], { fromMs: T, toMs: T + 2 * q })
1024
- assert.equal(r.energy?.peakKW, 900)
1025
- assert.equal(r.energy?.peakGrossKW, 1200)
1026
- assert.equal(r.energy?.shavedKW, 300, '1,200 − 900 — 그 순간 계통에서 덜어낸 몫')
1027
- })
1028
-
1029
- test('깎인 것이 없으면 그 값을 만들지 않는다 — 0 을 성과로 적지 않는다', () => {
1030
- const r = foldKpi([winNet(T, T + q, 300, 400, 400)], { fromMs: T, toMs: T + q })
1031
- assert.equal(r.energy?.peakGrossKW, 400)
1032
- assert.equal(r.energy?.shavedKW, undefined)
1033
- })
1034
-
1035
- test('총부하를 싣지 않은 옛 구간은 그대로 — 순수요와 같다고 단정하지 않는다', () => {
1036
- const r = foldKpi([winMax(T, T + q, 400, 900)], { fromMs: T, toMs: T + q })
1037
- assert.equal(r.energy?.peakKW, 900)
1038
- assert.equal(r.energy?.peakGrossKW, undefined)
1039
- assert.equal(r.energy?.shavedKW, undefined)
1040
- })
1041
-
1042
- /*
1043
- * ── 분기 계량이 있으면 배분하지 않는다 (2026-08-18) ───────────────────────────
1044
- * 실 게이트웨이는 분기마다 계량기를 보낸다. 그런데 화면은 「나누지 못함」으로 답했다 — 폴드에 가동시간
1045
- * 배분 경로만 있었기 때문이다. 미러 트윈에는 설비 가동 기록조차 없어(원천이 계량만 준다) 그 경로로는
1046
- * 분기별 값이 영원히 나오지 않았다.
1047
- */
1048
- const windowFact = (startMs: number, points: { id: string; kWh: number; root?: boolean }[], meanKW = 400) => ({
1049
- eventType: 'energy.demand.window',
1050
- eventTime: new Date(startMs + 900_000).toISOString(),
1051
- instanceId: 'ems-1',
1052
- payload: {
1053
- data: {
1054
- startMs,
1055
- endMs: startMs + 900_000,
1056
- meanKW,
1057
- maxKW: meanKW,
1058
- samples: 30,
1059
- points: points.map(p => ({ id: p.id, kWh: p.kWh, ...(p.root ? { root: true } : {}) }))
1060
- }
1061
- }
1062
- })
1063
-
1064
- test('분기별 값이 계량에서 온다 — 근거는 measured, 가동시간 이야기를 하지 않는다', () => {
1065
- const t0 = Date.parse('2026-08-18T00:00:00.000Z')
1066
- const r = foldKpi([windowFact(t0, [
1067
- { id: 'meter-incoming', kWh: 100, root: true },
1068
- { id: 'meter-a', kWh: 40 },
1069
- { id: 'meter-b', kWh: 30 }
1070
- ]) as any], { fromMs: t0, toMs: t0 + 900_000 }, {
1071
- meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1072
- })
1073
- const e = r.energy!
1074
- const byKey = new Map((e.byFeeder ?? []).map(f => [f.key, f]))
1075
- assert.equal(byKey.get('feeder-a')?.kWh, 40)
1076
- assert.equal(byKey.get('feeder-a')?.basis, 'measured')
1077
- assert.equal(byKey.get('feeder-a')?.weightKind, undefined, '나누지 않았으니 무엇으로 나눴다고 말하지 않는다')
1078
- assert.equal(byKey.get('feeder-b')?.kWh, 30)
1079
- })
1080
-
1081
- test('하위 계량이 덮지 않는 나머지를 남긴다 — 어느 분기에 얹지 않는다', () => {
1082
- const t0 = Date.parse('2026-08-18T01:00:00.000Z')
1083
- /* 구간 평균 400kW × 0.25h = 100kWh 가 총량이고, 분기 합은 70 이다. */
1084
- const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 30 }]) as any],
1085
- { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
1086
- const e = r.energy!
1087
- assert.equal(Math.round(e.kWh!), 100)
1088
- assert.equal(Math.round(e.unattributedKWh!), 30)
1089
- assert.equal(e.unattributedReason, 'not-submetered', '「선언이 없다」가 아니라 「계량이 덮지 않는다」')
1090
- const sum = (e.byFeeder ?? []).reduce((s, f) => s + f.kWh, 0) + (e.unattributedKWh ?? 0)
1091
- assert.ok(Math.abs(sum - e.kWh!) < 1e-6, `합이 보존된다(${sum} vs ${e.kWh})`)
1092
- })
1093
-
1094
- test('분기 합이 뿌리를 넘으면 그 사실을 낸다 — 깎아 맞추지 않는다', () => {
1095
- const t0 = Date.parse('2026-08-18T02:00:00.000Z')
1096
- const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 90 }, { id: 'meter-b', kWh: 30 }]) as any],
1097
- { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
1098
- const e = r.energy!
1099
- assert.equal(Math.round(e.submeterExcessKWh!), 20, '120 − 100')
1100
- assert.equal(e.byFeeder?.find(f => f.key === 'feeder-a')?.kWh, 90, '계량값은 그대로 남는다')
1101
- })
1102
-
1103
- test('계량 지도가 없으면 예전 배분 경로 그대로 — 거동을 바꾸지 않는다', () => {
1104
- const t0 = Date.parse('2026-08-18T03:00:00.000Z')
1105
- const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }]) as any],
1106
- { fromMs: t0, toMs: t0 + 900_000 }, { resourceFeeder: { 'press-1': 'feeder-a', 'press-2': 'feeder-b' } })
1107
- const e = r.energy!
1108
- /* 선언된 부하가 둘인데 가동 기록이 없으니 나눌 몫이 없다 — 총량은 그대로 남고 이유가 붙는다(예전 거동). */
1109
- assert.equal(e.unattributedReason, 'no-weights')
1110
- assert.equal(e.byFeeder, undefined, '계량 경로를 타지 않았다')
1111
- })
1112
-
1113
- test('피크를 만든 분기를 그 순간의 kW 로 답한다 — 전력량 큰 분기와 다를 수 있다', () => {
1114
- const t0 = Date.parse('2026-08-18T06:00:00.000Z')
1115
- const t1 = t0 + 900_000
1116
- /* 두 구간: 앞 구간이 전력량은 크지만(a 가 꾸준히), 피크는 뒤 구간에서 b 가 만든다. */
1117
- const w0 = {
1118
- eventType: 'energy.demand.window',
1119
- eventTime: new Date(t1).toISOString(),
1120
- instanceId: 'ems-1',
1121
- payload: { data: { startMs: t0, endMs: t1, meanKW: 400, maxKW: 500, samples: 30,
1122
- points: [{ id: 'meter-a', kWh: 80 }, { id: 'meter-b', kWh: 20 }],
1123
- pointsAtPeak: [{ id: 'meter-a', kW: 450 }, { id: 'meter-b', kW: 50 }] } }
1124
- }
1125
- const w1 = {
1126
- eventType: 'energy.demand.window',
1127
- eventTime: new Date(t1 + 900_000).toISOString(),
1128
- instanceId: 'ems-1',
1129
- payload: { data: { startMs: t1, endMs: t1 + 900_000, meanKW: 300, maxKW: 900, samples: 30,
1130
- points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 35 }],
1131
- pointsAtPeak: [{ id: 'meter-a', kW: 300 }, { id: 'meter-b', kW: 600 }] } }
1132
- }
1133
- const r = foldKpi([w0, w1] as any, { fromMs: t0, toMs: t1 + 900_000 }, {
1134
- meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1135
- })
1136
- const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
1137
- assert.equal(byKey.get('feeder-a')?.kWh, 120, '전력량은 a 가 크다')
1138
- /* 피크 값은 **최대수요를 낸 구간 하나**에서 온다 — 두 구간을 섞으면 다른 순간의 값을 더하는 것이다. */
1139
- assert.equal(byKey.get('feeder-b')?.peakKW, 600, '피크를 만든 것은 b 다')
1140
- assert.equal(byKey.get('feeder-a')?.peakKW, 300)
1141
- })
1142
-
1143
- test('피크 구간에 표본이 없던 분기는 피크 값을 만들지 않는다', () => {
1144
- const t0 = Date.parse('2026-08-18T07:00:00.000Z')
1145
- const w = {
1146
- eventType: 'energy.demand.window',
1147
- eventTime: new Date(t0 + 900_000).toISOString(),
1148
- instanceId: 'ems-1',
1149
- payload: { data: { startMs: t0, endMs: t0 + 900_000, meanKW: 200, maxKW: 300, samples: 10,
1150
- points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 10 }],
1151
- pointsAtPeak: [{ id: 'meter-a', kW: 300 }] } }
1152
- }
1153
- const r = foldKpi([w] as any, { fromMs: t0, toMs: t0 + 900_000 }, {
1154
- meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1155
- })
1156
- const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
1157
- assert.equal(byKey.get('feeder-a')?.peakKW, 300)
1158
- assert.equal(byKey.get('feeder-b')?.peakKW, undefined, '그 순간 값을 모르면 만들지 않는다')
1159
- })
1160
-
1161
- /*
1162
- * ── 완료로 읽지 못한 오더 상태 낱말을 **센다** (2026-08-23) ──────────────────
1163
- *
1164
- * 오더의 상태 어휘는 계약이 도메인에 넘긴 것이라(`picking`·`shipped`·`FINISHED`…) 이 폴드는 「완료로
1165
- * 읽히는 표현」을 동의어 목록으로 받는다. 그 대가는 **목록에 없는 낱말이 알리지 않고 0 으로 세어진다**는 것이다.
1166
- *
1167
- * 실제로 그랬다: `finished` 가 빠져 있어 첫 실 시스템의 완료 오더가 전부 0 이었고, 사람이 화면을 보고
1168
- * 의심해서야 드러났다. 목록을 넓히는 것으로는 다음 원본을 막지 못한다 — 그래서 **못 읽은 것을 센다.**
1169
- */
1170
- test('완료로 센 낱말은 여기 오지 않는다 — 이 값은 세지 않은 것의 인구조사다', () => {
1171
- const r = foldKpi(
1172
- [order('o-1', 'completed', 10), order('o-2', 'CANCELLED', 20), order('o-3', 'cancelled', 30)],
1173
- WINDOW
1174
- )
1175
- assert.equal(r.throughput.orders, 3, '대소문자를 가리지 않는다')
1176
- assert.deepEqual(r.orderStatusNotCounted, {}, '완료로 센 낱말은 여기 오지 않는다')
1177
- })
1178
-
1179
- test('세지 않은 낱말이 건수와 함께 남는다 — 완료 0 의 원인이 곧 보이게', () => {
1180
- const r = foldKpi(
1181
- [order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 20), order('o-3', 'picking', 30)],
1182
- WINDOW
1183
- )
1184
- assert.equal(r.throughput.orders, 0, '목록에 없는 낱말을 완료로 세지 않는다 — 추론하지 않는 것이 옳다')
1185
- assert.deepEqual(
1186
- r.orderStatusNotCounted,
1187
- { closed: 2, picking: 1 },
1188
- '무엇을 몇 건 세지 않았는지 말한다 — picking 이 함께 오는 것은 정상이다(완료가 아니다)'
1189
- )
1190
- })
1191
-
1192
- test('창 밖의 낱말은 세지 않는다 — 이 값도 창의 사실이다', () => {
1193
- const r = foldKpi([order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 7200)], WINDOW)
1194
- assert.deepEqual(r.orderStatusNotCounted, { closed: 1 }, '창 안의 것만 센다')
1195
- })
1196
-
1197
- test('상태를 말하지 않은 사건은 여기 오지 않는다 — 그것은 다른 결함이다', () => {
1198
- /*
1199
- * 빈 낱말을 여기 섞으면 이 값의 뜻이 흐려진다 — 상태가 없는 사건은 **다른 결함**이고(유입 문이 상태를
1200
- * 필수로 받는다) 그 진단은 그 문의 몫이다.
1201
- */
1202
- const r = foldKpi([{ eventType: 'order.status', eventTime: at(10), payload: { data: { orderId: 'o-1' } } }], WINDOW)
1203
- assert.deepEqual(r.orderStatusNotCounted, {})
1204
- })
1205
-
1206
- /* ── 밖에서 마감되어 온 사용 구간과 청구서 (2026-08-29) ────────────────────
1207
- *
1208
- * ★ 계량 표본이 오지 않는 현장이 있다. 원본이 「이 구간에 X 썼다」를 이미 마감해 준다.
1209
- * 그 값을 쓰지 않으면 그 현장의 화면은 영영 빈손이다.
1210
- *
1211
- * 두 회계를 **더하지 않는다** — 같은 전기를 두 번 센다.
1212
- */
1213
-
1214
- const usagePeriod = (kWh: number, fromSec: number, toSec: number, extra: Record<string, unknown> = {}): KpiEvent => ({
1215
- eventType: 'energy.usage.period',
1216
- eventTime: at(toSec),
1217
- payload: { data: { meterId: 'incoming-1', from: at(fromSec), to: at(toSec), kWh, ...extra } }
1218
- })
1219
-
1220
- test('★ 계량 표본이 없어도 사용 구간으로 답한다 — 근거를 밝힌다', () => {
1221
- const k = foldKpi([usagePeriod(474.48, 0, 1800, { basis: 'provider' }), usagePeriod(471.6, 1800, 3600, { basis: 'provider' })], WINDOW)
1222
- assert.equal(k.energy?.kWh, 474.48 + 471.6)
1223
- assert.equal(k.energy?.kWhBasis, 'usage-period')
1224
- assert.equal(k.energy?.usage?.counted, 2)
1225
- assert.deepEqual(k.energy?.usage?.byBasis, [{ basis: 'provider', counted: 2 }])
1226
- })
1227
-
1228
- test('창에 걸친 구간은 세지 않는다 — 반만 세면 합이 창을 넘는다', () => {
1229
- const k = foldKpi([usagePeriod(100, -1800, 1800)], WINDOW)
1230
- assert.equal(k.energy?.usage, undefined)
1231
- })
1232
-
1233
- test('★ 구간마다 제 단가로 매긴다 — 단일 단가로 접지 않는다', () => {
1234
- /* 새벽 87.3 · 낮 222.3 — 하나로 접으면 「피크를 깎아 얼마를 아끼나」가 사라진다. */
1235
- const k = foldKpi(
1236
- [
1237
- usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW', basis: 'provider' }),
1238
- usagePeriod(100, 1800, 3600, { unitPrice: 222.3, currency: 'KRW', basis: 'provider' })
1239
- ],
1240
- WINDOW,
1241
- { tariff: { energyChargePerKWh: 140.2, currency: 'KRW' } } as any
1242
- )
1243
- assert.equal(k.energy?.usage?.charge, 100 * 87.3 + 100 * 222.3)
1244
- assert.equal((k.energy?.cost as any)?.energyCharge, 100 * 87.3 + 100 * 222.3, '구간별 단가가 이긴다')
1245
- assert.ok(!(k.energy?.cost as any)?.notModeled.includes('tou'))
1246
- })
1247
-
1248
- test('일부만 단가를 실었으면 요금을 만들지 않고 그 수를 낸다', () => {
1249
- const k = foldKpi(
1250
- [usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW' }), usagePeriod(100, 1800, 3600)],
1251
- WINDOW
1252
- )
1253
- assert.equal(k.energy?.usage?.charge, undefined, '있는 것만 더하면 그 합이 창의 요금인 척한다')
1254
- assert.equal(k.energy?.usage?.unpriced, 1)
1255
- })
1256
-
1257
- test('★ 청구서는 사실로 나란히 둔다 — 창이 다르면 견주지 않는다', () => {
1258
- const bill: KpiEvent = {
1259
- eventType: 'energy.bill',
1260
- eventTime: at(3600),
1261
- payload: { data: { from: at(600), to: at(3000), total: 1_000_000, currency: 'KRW', billingDemandKW: 1650 } }
1262
- }
1263
- const k = foldKpi([bill], WINDOW)
1264
- assert.equal(k.energy?.bill?.total, 1_000_000)
1265
- assert.equal(k.energy?.bill?.billingDemandKW, 1650)
1266
- assert.equal(k.energy?.bill?.matchesWindow, false, '한 달 청구서를 하루 창의 값과 빼면 아무것도 뜻하지 않는다')
1267
- assert.equal(k.energy?.bill?.ourTotalMinusBilled, undefined)
1268
- })
1269
-
1270
- test('★ 창이 청구 주기와 같으면 우리 계산과의 차이를 낸다 — 0 이 아니면 우리가 틀렸다', () => {
1271
- const bill: KpiEvent = {
1272
- eventType: 'energy.bill',
1273
- eventTime: at(3600),
1274
- payload: { data: { from: at(0), to: at(3600), total: 100_000, currency: 'KRW' } }
1275
- }
1276
- const k = foldKpi([bill, usagePeriod(1_000, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
1277
- tariff: { energyChargePerKWh: 87.3, currency: 'KRW' }
1278
- } as any)
1279
- assert.equal(k.energy?.bill?.matchesWindow, true)
1280
- assert.equal(k.energy?.bill?.diff?.total, 1_000 * 87.3 - 100_000)
1281
- })
1282
-
1283
- test('★ 총액이 없는 청구서도 항목별로 견준다 — 실제 첫 청구서에 기본요금만 있었다', () => {
1284
- const bill: KpiEvent = {
1285
- eventType: 'energy.bill',
1286
- eventTime: at(3600),
1287
- payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW', billingDemandKW: 1650 } }
1288
- }
1289
- const k = foldKpi([bill], WINDOW, {
1290
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1_650, currency: 'KRW' }
1291
- } as any)
1292
- assert.equal(k.energy?.bill?.diff?.demandCharge, 0, '요금적용전력으로 매기면 청구서와 같다')
1293
- assert.equal(k.energy?.bill?.diff?.total, undefined, '한쪽에 없는 항목은 견주지 않는다')
1294
- })
1295
-
1296
- test('요금적용전력을 선언하지 않으면 기본요금이 어긋나는 것이 드러난다', () => {
1297
- const bill: KpiEvent = {
1298
- eventType: 'energy.bill',
1299
- eventTime: at(3600),
1300
- payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW' } }
1301
- }
1302
- /* 선언이 없으면 커널은 창의 피크로 조건부 값을 낸다 — 그 차이가 그대로 나온다. */
1303
- const peakWindow: KpiEvent = {
1304
- eventType: 'energy.demand.window',
1305
- eventTime: at(1800),
1306
- payload: { data: { startMs: T0 + 900_000, endMs: T0 + 1_800_000, maxKW: 600, meanKW: 600, samples: 4 } }
1307
- }
1308
- const k = foldKpi([bill, peakWindow], WINDOW, {
1309
- tariff: { demandChargePerKW: 8_320, currency: 'KRW' }
1310
- } as any)
1311
- const d = k.energy?.bill?.diff?.demandCharge
1312
- assert.ok(d !== undefined && d !== 0, `어긋남이 드러나야 한다: ${d}`)
1313
- })
1314
-
1315
- /* ── 요금적용전력은 청구서가 먼저다 (2026-08-29) ───────────────────────────
1316
- *
1317
- * ★ 그 값은 최근 몇 달의 최고 수요로 **매달 다시 정해진다.** 선언에 박아 두면 사람이 매달
1318
- * 갱신해야 하고, 안 하면 낡은 값으로 기본요금이 조용히 틀린다.
1319
- */
1320
-
1321
- const billEvent = (fromSec: number, toSec: number, data: Record<string, unknown>): KpiEvent => ({
1322
- eventType: 'energy.bill',
1323
- eventTime: at(toSec),
1324
- payload: { data: { from: at(fromSec), to: at(toSec), currency: 'KRW', ...data } }
1325
- })
1326
-
1327
- test('★ 창을 덮는 청구서가 있으면 그 값으로 매긴다', () => {
1328
- const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
1329
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' }
1330
- } as any)
1331
- assert.equal(k.energy?.billingDemand?.kW, 1650)
1332
- assert.equal(k.energy?.billingDemand?.from, 'bill')
1333
- assert.equal((k.energy?.cost as any)?.demandCharge, 1650 * 8_320, '선언 1200 이 아니라 청구서 1650 으로 매긴다')
1334
- })
1335
-
1336
- test('★ 선언이 청구서와 다르면 낡았다고 말한다 — 감추면 아무도 다시 읽지 않는다', () => {
1337
- const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
1338
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' }
1339
- } as any)
1340
- assert.equal(k.energy?.billingDemand?.declaredKW, 1200)
1341
- assert.equal(k.energy?.billingDemand?.stale, true)
1342
- })
1343
-
1344
- test('선언과 청구서가 같으면 낡았다고 하지 않는다', () => {
1345
- const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
1346
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
1347
- } as any)
1348
- assert.equal(k.energy?.billingDemand?.stale, undefined)
1349
- })
1350
-
1351
- test('★ 창에 청구서가 없으면 마지막 청구서의 값을 이어 쓴다 — 요금 제도가 그렇게 군다', () => {
1352
- const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
1353
- tariff: { demandChargePerKW: 8_320, currency: 'KRW' },
1354
- lastBill: { to: at(-86400), billingDemandKW: 1650 }
1355
- } as any)
1356
- assert.equal(k.energy?.billingDemand?.kW, 1650)
1357
- assert.equal(k.energy?.billingDemand?.from, 'carried-forward')
1358
- assert.equal((k.energy?.cost as any)?.demandBasis, 'billing-demand')
1359
- })
1360
-
1361
- test('청구가 오지 않는 현장은 선언을 쓴다', () => {
1362
- const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
1363
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 900, currency: 'KRW' }
1364
- } as any)
1365
- assert.equal(k.energy?.billingDemand?.from, 'declaration')
1366
- assert.equal(k.energy?.billingDemand?.kW, 900)
1367
- assert.equal(k.energy?.billingDemand?.stale, undefined)
1368
- })
1369
-
1370
- test('셋 다 없으면 이 칸을 만들지 않는다 — 빈 값을 지어내지 않는다', () => {
1371
- const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
1372
- tariff: { demandChargePerKW: 8_320, currency: 'KRW' }
1373
- } as any)
1374
- assert.equal(k.energy?.billingDemand, undefined)
1375
- })
1376
-
1377
- test('★ 관측한 최고가 청구 기준을 넘으면 얼마나 넘었는지와 언제까지의 값인지를 낸다', () => {
1378
- /* 기본요금은 이 값으로 다시 정해진다 — 지금 줄여도 되돌아가지 않는다. */
1379
- const k = foldKpi([usagePeriod(400, 0, 1800, { maxKW: 1200 }), usagePeriod(400, 1800, 3600, { maxKW: 1700 })], WINDOW, {
1380
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
1381
- } as any)
1382
- const e = k.energy?.billingDemand?.exceeded
1383
- assert.equal(e?.peakKW, 1700)
1384
- assert.equal(e?.overKW, 50)
1385
- assert.equal(e?.observedTo, at(3600), '이 최고값이 언제까지의 것인지')
1386
- })
1387
-
1388
- test('넘지 않으면 그 칸을 만들지 않는다', () => {
1389
- const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1200 })], WINDOW, {
1390
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
1391
- } as any)
1392
- assert.equal(k.energy?.billingDemand?.exceeded, undefined)
1393
- })
1394
-
1395
- test('청구서가 말한 기준으로 넘었는지 본다 — 선언이 아니다', () => {
1396
- const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1300 })], WINDOW, {
1397
- tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' },
1398
- lastBill: { to: at(-86400), billingDemandKW: 1200 }
1399
- } as any)
1400
- assert.equal(k.energy?.billingDemand?.kW, 1200)
1401
- assert.equal(k.energy?.billingDemand?.exceeded?.overKW, 100)
1402
- })
1403
-
1404
- /* ── 이용률 (2026-08-30) ───────────────────────────────────────────────────
1405
- *
1406
- * ★ 분모가 어느 정격인지가 값의 뜻을 바꾼다. 붙어 있는 원본은 직류로 나눠 2.2% 를 보여 주는데,
1407
- * 교류로 나누면 4.08% 다. 같은 발전소를 두 화면에서 보는 사람이 두 배 다른 수를 본다.
1408
- *
1409
- * 그래서 커널은 교류를 먼저 쓰되(계통으로 나가는 양은 교류 정격을 넘을 수 없다) **어느 쪽으로
1410
- * 나눴는지를 값과 함께 낸다.**
1411
- */
1412
-
1413
- const generationPeriod = (kWh: number, fromSec: number, toSec: number): KpiEvent => ({
1414
- eventType: 'energy.generation.period',
1415
- eventTime: at(toSec),
1416
- payload: {
1417
- data: { equipmentId: 'inv-1', kWh, periodStart: at(fromSec), periodEnd: at(toSec), observedFrom: at(fromSec), observedTo: at(toSec) }
1418
- }
1419
- })
1420
-
1421
- test('★ 이용률은 만든 양 ÷ (정격 × 그 기간의 시간)', () => {
1422
- /* 한 시간에 63.8kWh · 정격 127.6kW → 50% */
1423
- const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
1424
- generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
1425
- } as any)
1426
- const cf = k.energy?.generated?.capacityFactor
1427
- assert.equal(cf?.value, 0.5)
1428
- assert.equal(cf?.ratedKW, 127.6)
1429
- assert.equal(cf?.hours, 1)
1430
- })
1431
-
1432
- test('★ 어느 정격으로 나눴는지 함께 낸다 — 두 화면의 수가 달라도 이유를 말할 수 있게', () => {
1433
- const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
1434
- generationRated: { kW: 234.35, basis: 'dc', from: 'location' }
1435
- } as any)
1436
- assert.equal(k.energy?.generated?.capacityFactor?.basis, 'dc')
1437
- assert.equal(k.energy?.generated?.capacityFactor?.from, 'location')
1438
- })
1439
-
1440
- test('★ 창 길이가 아니라 센 기간들의 길이로 나눈다', () => {
1441
- /*
1442
- * 한 시간짜리 기간 하나만 센 창에서 창 길이(1시간)로 나누는 것과 같지만, 기간이 창보다 짧으면
1443
- * 달라진다. 창 길이로 나누면 그 기간이 창 전체에 걸쳐 있었던 것처럼 작아진다.
1444
- */
1445
- const k = foldKpi([generationPeriod(63.8, 0, 1800)], WINDOW, {
1446
- generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
1447
- } as any)
1448
- assert.equal(k.energy?.generated?.capacityFactor?.hours, 0.5)
1449
- assert.equal(k.energy?.generated?.capacityFactor?.value, 1, '30분에 정격만큼 만들었으면 100%다')
1450
- })
1451
-
1452
- test('정격이 없으면 이용률을 내지 않는다 — 0 이나 짐작을 만들지 않는다', () => {
1453
- const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW)
1454
- assert.equal(k.energy?.generated?.capacityFactor, undefined)
1455
- assert.equal(k.energy?.generated?.kWh, 63.8, '발전량 자체는 그대로 낸다')
1456
- })