@things-factory/headless-twin 10.0.9 → 10.0.11

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 (354) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +2 -2
  2. package/dist-server/engine/canonical-ingest.js +24 -2
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/command-routing.js +1 -1
  5. package/dist-server/engine/command-routing.js.map +1 -1
  6. package/dist-server/engine/energy-topology.d.ts +86 -0
  7. package/dist-server/engine/energy-topology.js +144 -0
  8. package/dist-server/engine/energy-topology.js.map +1 -0
  9. package/dist-server/engine/index.d.ts +8 -3
  10. package/dist-server/engine/index.js +10 -3
  11. package/dist-server/engine/index.js.map +1 -1
  12. package/dist-server/engine/kpi-baseline.d.ts +1 -1
  13. package/dist-server/engine/kpi-baseline.js.map +1 -1
  14. package/dist-server/engine/kpi-fold.d.ts +137 -1
  15. package/dist-server/engine/kpi-fold.js +225 -0
  16. package/dist-server/engine/kpi-fold.js.map +1 -1
  17. package/dist-server/engine/kpi-query.d.ts +1 -1
  18. package/dist-server/engine/kpi-query.js +212 -18
  19. package/dist-server/engine/kpi-query.js.map +1 -1
  20. package/dist-server/engine/kpi-target.d.ts +17 -0
  21. package/dist-server/engine/kpi-target.js +24 -2
  22. package/dist-server/engine/kpi-target.js.map +1 -1
  23. package/dist-server/engine/live-feed-registry.d.ts +25 -0
  24. package/dist-server/engine/live-feed-registry.js +51 -0
  25. package/dist-server/engine/live-feed-registry.js.map +1 -0
  26. package/dist-server/engine/load-meter.d.ts +181 -0
  27. package/dist-server/engine/load-meter.js +267 -0
  28. package/dist-server/engine/load-meter.js.map +1 -0
  29. package/dist-server/engine/local-declarations.d.ts +262 -0
  30. package/dist-server/engine/local-declarations.js +528 -0
  31. package/dist-server/engine/local-declarations.js.map +1 -0
  32. package/dist-server/engine/model-basis.d.ts +39 -6
  33. package/dist-server/engine/model-basis.js +69 -9
  34. package/dist-server/engine/model-basis.js.map +1 -1
  35. package/dist-server/engine/{board-vocabulary.d.ts → model-vocabulary.d.ts} +4 -4
  36. package/dist-server/engine/{board-vocabulary.js → model-vocabulary.js} +37 -19
  37. package/dist-server/engine/model-vocabulary.js.map +1 -0
  38. package/dist-server/engine/oee-accumulator.d.ts +28 -0
  39. package/dist-server/engine/oee-accumulator.js +26 -1
  40. package/dist-server/engine/oee-accumulator.js.map +1 -1
  41. package/dist-server/engine/operation-basis.d.ts +32 -0
  42. package/dist-server/engine/operation-basis.js +87 -0
  43. package/dist-server/engine/operation-basis.js.map +1 -0
  44. package/dist-server/engine/property-effects.d.ts +30 -0
  45. package/dist-server/engine/property-effects.js +192 -0
  46. package/dist-server/engine/property-effects.js.map +1 -0
  47. package/dist-server/engine/runtime-key.d.ts +1 -1
  48. package/dist-server/engine/runtime-key.js +1 -1
  49. package/dist-server/engine/runtime-key.js.map +1 -1
  50. package/dist-server/engine/spec-coverage.js +3 -3
  51. package/dist-server/engine/spec-coverage.js.map +1 -1
  52. package/dist-server/engine/state-axes.d.ts +7 -2
  53. package/dist-server/engine/state-axes.js +8 -3
  54. package/dist-server/engine/state-axes.js.map +1 -1
  55. package/dist-server/engine/structure-diff.js +1 -1
  56. package/dist-server/engine/structure-diff.js.map +1 -1
  57. package/dist-server/engine/travel-estimator.d.ts +9 -2
  58. package/dist-server/engine/travel-estimator.js +11 -6
  59. package/dist-server/engine/travel-estimator.js.map +1 -1
  60. package/dist-server/engine/twin-engine.d.ts +332 -47
  61. package/dist-server/engine/twin-engine.js +1314 -279
  62. package/dist-server/engine/twin-engine.js.map +1 -1
  63. package/dist-server/engine/warm-start.d.ts +38 -4
  64. package/dist-server/engine/warm-start.js +38 -9
  65. package/dist-server/engine/warm-start.js.map +1 -1
  66. package/dist-server/index.d.ts +1 -0
  67. package/dist-server/index.js +17 -11
  68. package/dist-server/index.js.map +1 -1
  69. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.d.ts +5 -0
  70. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js +48 -0
  71. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js.map +1 -0
  72. package/dist-server/migrations/index.d.ts +2 -0
  73. package/dist-server/migrations/index.js +10 -0
  74. package/dist-server/migrations/index.js.map +1 -0
  75. package/dist-server/service/index.d.ts +3 -2
  76. package/dist-server/service/index.js +25 -17
  77. package/dist-server/service/index.js.map +1 -1
  78. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  79. package/dist-server/service/reference/discovery-result.js +2 -2
  80. package/dist-server/service/reference/discovery-result.js.map +1 -1
  81. package/dist-server/service/reference/ingest-space.d.ts +24 -1
  82. package/dist-server/service/reference/ingest-space.js +17 -1
  83. package/dist-server/service/reference/ingest-space.js.map +1 -1
  84. package/dist-server/service/reference/reference-live.d.ts +23 -0
  85. package/dist-server/service/reference/reference-live.js +121 -4
  86. package/dist-server/service/reference/reference-live.js.map +1 -1
  87. package/dist-server/service/reference/reference-master.d.ts +194 -13
  88. package/dist-server/service/reference/reference-master.js +159 -28
  89. package/dist-server/service/reference/reference-master.js.map +1 -1
  90. package/dist-server/service/reference/reference-resolver.d.ts +31 -1
  91. package/dist-server/service/reference/reference-resolver.js +223 -12
  92. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  93. package/dist-server/service/reference/template-registry.d.ts +19 -3
  94. package/dist-server/service/reference/template-registry.js.map +1 -1
  95. package/dist-server/service/twin-audit/command-audit.d.ts +37 -0
  96. package/dist-server/service/twin-audit/command-audit.js +53 -0
  97. package/dist-server/service/twin-audit/command-audit.js.map +1 -0
  98. package/dist-server/service/twin-audit/index.d.ts +4 -0
  99. package/dist-server/service/twin-audit/index.js +8 -0
  100. package/dist-server/service/twin-audit/index.js.map +1 -0
  101. package/dist-server/service/twin-audit/twin-audit-event.d.ts +24 -0
  102. package/dist-server/service/twin-audit/twin-audit-event.js +141 -0
  103. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -0
  104. package/dist-server/service/twin-audit/twin-audit-query.d.ts +4 -0
  105. package/dist-server/service/twin-audit/twin-audit-query.js +76 -0
  106. package/dist-server/service/twin-audit/twin-audit-query.js.map +1 -0
  107. package/dist-server/service/twin-control/twin-control-mutation.js +21 -1
  108. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  109. package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
  110. package/dist-server/service/twin-event/twin-event-keys.js +1 -1
  111. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  112. package/dist-server/service/twin-event/twin-event.d.ts +14 -5
  113. package/dist-server/service/twin-event/twin-event.js +45 -13
  114. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  115. package/dist-server/service/twin-forecast/forecast-metrics.d.ts +13 -0
  116. package/dist-server/service/twin-forecast/forecast-metrics.js +57 -0
  117. package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -0
  118. package/dist-server/service/twin-forecast/forecast-tuning.d.ts +12 -0
  119. package/dist-server/service/twin-forecast/forecast-tuning.js +44 -0
  120. package/dist-server/service/twin-forecast/forecast-tuning.js.map +1 -0
  121. package/dist-server/service/twin-forecast/gap-analytics.d.ts +28 -2
  122. package/dist-server/service/twin-forecast/gap-analytics.js +43 -17
  123. package/dist-server/service/twin-forecast/gap-analytics.js.map +1 -1
  124. package/dist-server/service/twin-forecast/twin-forecast-query.js +217 -38
  125. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  126. package/dist-server/service/twin-instance/twin-instance.d.ts +38 -5
  127. package/dist-server/service/twin-instance/twin-instance.js +66 -20
  128. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  129. package/dist-server/service/twin-journal/twin-journal-query.d.ts +5 -2
  130. package/dist-server/service/twin-journal/twin-journal-query.js +71 -9
  131. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  132. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +3 -5
  133. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +24 -27
  134. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  135. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  136. package/dist-server/service/twin-metrics/twin-metrics-query.js +62 -0
  137. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  138. package/dist-server/service/twin-model/epcis-coverage.d.ts +14 -0
  139. package/dist-server/service/twin-model/epcis-coverage.js +174 -0
  140. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -0
  141. package/dist-server/service/twin-model/iec61850-coverage.d.ts +14 -0
  142. package/dist-server/service/twin-model/iec61850-coverage.js +98 -0
  143. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -0
  144. package/dist-server/service/twin-model/index.d.ts +13 -0
  145. package/dist-server/service/twin-model/index.js +29 -0
  146. package/dist-server/service/twin-model/index.js.map +1 -0
  147. package/dist-server/service/twin-model/isa95-coverage.d.ts +48 -0
  148. package/dist-server/service/twin-model/isa95-coverage.js +155 -0
  149. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -0
  150. package/dist-server/service/twin-model/project-structure.d.ts +16 -0
  151. package/dist-server/service/twin-model/project-structure.js +173 -0
  152. package/dist-server/service/twin-model/project-structure.js.map +1 -0
  153. package/dist-server/service/twin-model/standard-coverage.d.ts +9 -0
  154. package/dist-server/service/twin-model/standard-coverage.js +37 -0
  155. package/dist-server/service/twin-model/standard-coverage.js.map +1 -0
  156. package/dist-server/service/twin-model/twin-equipment.d.ts +64 -0
  157. package/dist-server/service/twin-model/twin-equipment.js +134 -0
  158. package/dist-server/service/twin-model/twin-equipment.js.map +1 -0
  159. package/dist-server/service/twin-model/twin-lineage-query.d.ts +3 -0
  160. package/dist-server/service/twin-model/twin-lineage-query.js +206 -0
  161. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -0
  162. package/dist-server/service/twin-model/twin-location.d.ts +58 -0
  163. package/dist-server/service/twin-model/twin-location.js +130 -0
  164. package/dist-server/service/twin-model/twin-location.js.map +1 -0
  165. package/dist-server/service/twin-model/twin-model-item-query.d.ts +141 -0
  166. package/dist-server/service/twin-model/twin-model-item-query.js +852 -0
  167. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -0
  168. package/dist-server/service/twin-model/twin-model-mutation.d.ts +57 -0
  169. package/dist-server/service/twin-model/twin-model-mutation.js +490 -0
  170. package/dist-server/service/twin-model/twin-model-mutation.js.map +1 -0
  171. package/dist-server/service/twin-model/twin-model-query.d.ts +43 -0
  172. package/dist-server/service/twin-model/twin-model-query.js +551 -0
  173. package/dist-server/service/twin-model/twin-model-query.js.map +1 -0
  174. package/dist-server/service/twin-model/twin-model-tree-query.d.ts +3 -0
  175. package/dist-server/service/twin-model/twin-model-tree-query.js +158 -0
  176. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -0
  177. package/dist-server/service/twin-model/twin-operation.d.ts +61 -0
  178. package/dist-server/service/twin-model/twin-operation.js +137 -0
  179. package/dist-server/service/twin-model/twin-operation.js.map +1 -0
  180. package/dist-server/service/twin-space/move-space.d.ts +65 -0
  181. package/dist-server/service/twin-space/move-space.js +166 -0
  182. package/dist-server/service/twin-space/move-space.js.map +1 -0
  183. package/dist-server/service/twin-space/space-integrity.d.ts +97 -0
  184. package/dist-server/service/twin-space/space-integrity.js +182 -0
  185. package/dist-server/service/twin-space/space-integrity.js.map +1 -0
  186. package/dist-server/service/twin-space/twin-space-area.js +4 -4
  187. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  188. package/dist-server/service/twin-space/twin-space-representation.js +6 -6
  189. package/dist-server/service/twin-space/twin-space-representation.js.map +1 -1
  190. package/dist-server/service/twin-space/twin-space-resolver.d.ts +63 -1
  191. package/dist-server/service/twin-space/twin-space-resolver.js +356 -18
  192. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  193. package/dist-server/service/twin-space/twin-space.d.ts +17 -1
  194. package/dist-server/service/twin-space/twin-space.js +15 -5
  195. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  196. package/dist-server/service/twin-structure/twin-structure.d.ts +1 -1
  197. package/dist-server/service/twin-structure/twin-structure.js +8 -8
  198. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  199. package/dist-shared/axis-read.d.ts +12 -0
  200. package/dist-shared/axis-read.js +32 -0
  201. package/dist-shared/axis-read.js.map +1 -0
  202. package/{dist-server/engine → dist-shared}/entity-delta.d.ts +3 -0
  203. package/{dist-server/engine → dist-shared}/entity-delta.js +102 -2
  204. package/dist-shared/entity-delta.js.map +1 -0
  205. package/dist-shared/kpi-broadcast.js.map +1 -0
  206. package/dist-shared/twin-level.js.map +1 -0
  207. package/package.json +14 -12
  208. package/server/engine/canonical-ingest.ts +36 -4
  209. package/server/engine/command-routing.ts +1 -1
  210. package/server/engine/energy-topology.ts +189 -0
  211. package/server/engine/index.ts +10 -3
  212. package/server/engine/kpi-baseline.ts +1 -1
  213. package/server/engine/kpi-fold.ts +389 -1
  214. package/server/engine/kpi-query.ts +214 -19
  215. package/server/engine/kpi-target.ts +24 -2
  216. package/server/engine/live-feed-registry.ts +58 -0
  217. package/server/engine/load-meter.ts +384 -0
  218. package/server/engine/local-declarations.ts +700 -0
  219. package/server/engine/model-basis.ts +78 -10
  220. package/server/engine/{board-vocabulary.ts → model-vocabulary.ts} +36 -15
  221. package/server/engine/oee-accumulator.ts +34 -1
  222. package/server/engine/operation-basis.ts +100 -0
  223. package/server/engine/property-effects.ts +199 -0
  224. package/server/engine/runtime-key.ts +1 -1
  225. package/server/engine/spec-coverage.ts +3 -3
  226. package/server/engine/state-axes.ts +8 -3
  227. package/server/engine/structure-diff.ts +1 -1
  228. package/server/engine/travel-estimator.ts +11 -6
  229. package/server/engine/twin-engine.ts +1414 -274
  230. package/server/engine/warm-start.ts +83 -12
  231. package/server/index.ts +13 -10
  232. package/server/migrations/1786000000000-RenameTwinInstanceBoardToModel.ts +43 -0
  233. package/server/migrations/index.ts +7 -0
  234. package/server/service/index.ts +9 -1
  235. package/server/service/reference/discovery-result.ts +2 -2
  236. package/server/service/reference/ingest-space.ts +35 -1
  237. package/server/service/reference/reference-live.ts +119 -4
  238. package/server/service/reference/reference-master.ts +276 -35
  239. package/server/service/reference/reference-resolver.ts +232 -12
  240. package/server/service/reference/template-registry.ts +20 -5
  241. package/server/service/twin-audit/command-audit.ts +81 -0
  242. package/server/service/twin-audit/index.ts +5 -0
  243. package/server/service/twin-audit/twin-audit-event.ts +138 -0
  244. package/server/service/twin-audit/twin-audit-query.ts +72 -0
  245. package/server/service/twin-control/twin-control-mutation.ts +22 -1
  246. package/server/service/twin-event/twin-event-keys.ts +1 -1
  247. package/server/service/twin-event/twin-event.ts +59 -14
  248. package/server/service/twin-forecast/forecast-metrics.ts +60 -0
  249. package/server/service/twin-forecast/forecast-tuning.ts +40 -0
  250. package/server/service/twin-forecast/gap-analytics.ts +48 -9
  251. package/server/service/twin-forecast/twin-forecast-query.ts +208 -36
  252. package/server/service/twin-instance/twin-instance.ts +126 -25
  253. package/server/service/twin-journal/twin-journal-query.ts +74 -10
  254. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +23 -22
  255. package/server/service/twin-metrics/twin-metrics-query.ts +59 -2
  256. package/server/service/twin-model/epcis-coverage.ts +195 -0
  257. package/server/service/twin-model/iec61850-coverage.ts +117 -0
  258. package/server/service/twin-model/index.ts +25 -0
  259. package/server/service/twin-model/isa95-coverage.ts +190 -0
  260. package/server/service/twin-model/project-structure.ts +199 -0
  261. package/server/service/twin-model/standard-coverage.ts +35 -0
  262. package/server/service/twin-model/twin-equipment.ts +157 -0
  263. package/server/service/twin-model/twin-lineage-query.ts +193 -0
  264. package/server/service/twin-model/twin-location.ts +147 -0
  265. package/server/service/twin-model/twin-model-item-query.ts +848 -0
  266. package/server/service/twin-model/twin-model-mutation.ts +503 -0
  267. package/server/service/twin-model/twin-model-query.ts +537 -0
  268. package/server/service/twin-model/twin-model-tree-query.ts +180 -0
  269. package/server/service/twin-model/twin-operation.ts +157 -0
  270. package/server/service/twin-space/move-space.ts +262 -0
  271. package/server/service/twin-space/space-integrity.ts +293 -0
  272. package/server/service/twin-space/twin-space-area.ts +4 -4
  273. package/server/service/twin-space/twin-space-representation.ts +6 -6
  274. package/server/service/twin-space/twin-space-resolver.ts +355 -21
  275. package/server/service/twin-space/twin-space.ts +40 -6
  276. package/server/service/twin-structure/twin-structure.ts +10 -10
  277. package/shared/axis-read.ts +28 -0
  278. package/{server/engine → shared}/entity-delta.ts +96 -1
  279. package/test/adopt-structure-live.test.ts +133 -0
  280. package/test/attention-digest.test.ts +3 -3
  281. package/test/axis-read.test.ts +87 -0
  282. package/test/boot-resume.test.ts +205 -0
  283. package/test/canonical-ingest-vocabularies.test.ts +118 -0
  284. package/test/capability-mapping.test.ts +1 -1
  285. package/test/column-type-portability.test.ts +122 -0
  286. package/test/command-routing.test.ts +1 -1
  287. package/test/declaration-reaches-model.test.ts +178 -0
  288. package/test/discovery-result.test.ts +1 -1
  289. package/test/energy-topology.test.ts +113 -0
  290. package/test/entity-delta.test.ts +171 -1
  291. package/test/event-time-column.test.ts +67 -0
  292. package/test/forecast-metrics.test.ts +71 -0
  293. package/test/forecast-tuning.test.ts +42 -0
  294. package/test/gap-analytics.test.ts +34 -7
  295. package/test/ingest-bench.test.ts +6 -6
  296. package/test/ingest-running-guard.test.ts +136 -0
  297. package/test/ingest-space.test.ts +29 -1
  298. package/test/instance-cache-lifecycle.test.ts +110 -0
  299. package/test/kernel-kind-guard.test.ts +96 -0
  300. package/test/kpi-baseline-db.test.ts +3 -1
  301. package/test/kpi-baseline.test.ts +3 -3
  302. package/test/kpi-fold.test.ts +393 -2
  303. package/test/kpi-query-bench.test.ts +5 -3
  304. package/test/lineage-survives-restart.test.ts +203 -0
  305. package/test/live-feed-registry.test.ts +67 -0
  306. package/test/live-kernel-facts.test.ts +161 -0
  307. package/test/live-mirror-parity.test.ts +22 -15
  308. package/test/load-meter.test.ts +314 -0
  309. package/test/local-declarations.test.ts +554 -0
  310. package/test/master-to-twin.test.ts +121 -33
  311. package/test/model-basis.test.ts +56 -1
  312. package/test/{board-vocabulary.test.ts → model-vocabulary.test.ts} +25 -25
  313. package/test/move-space.test.ts +149 -0
  314. package/test/mutation-gate.test.ts +19 -5
  315. package/test/oee-accumulator.test.ts +26 -7
  316. package/test/operation-basis.test.ts +100 -0
  317. package/test/operations-capability-db.test.ts +165 -0
  318. package/test/project-structure-db.test.ts +226 -0
  319. package/test/projection-reaches-screen.test.ts +272 -0
  320. package/test/property-effects.test.ts +96 -0
  321. package/test/resync-origin-site.test.ts +77 -0
  322. package/test/scale-twin-bench.test.ts +5 -5
  323. package/test/snapshot-freshness.test.ts +60 -0
  324. package/test/space-integrity.test.ts +223 -0
  325. package/test/standard-coverage.test.ts +94 -0
  326. package/test/state-axes.test.ts +1 -1
  327. package/test/streamline-e2e.test.ts +14 -14
  328. package/test/structure-revision-db.test.ts +11 -11
  329. package/test/tenant-registry-db.test.ts +5 -5
  330. package/test/twin-audit.test.ts +83 -0
  331. package/test/twin-model-item-db.test.ts +275 -0
  332. package/test/twin-model-tree-db.test.ts +176 -0
  333. package/test/twin-origin-resync.test.ts +114 -0
  334. package/test/warm-start-seam.test.ts +6 -6
  335. package/test/warm-start.test.ts +88 -3
  336. package/tsconfig.json +6 -1
  337. package/tsconfig.shared.json +23 -0
  338. package/tsconfig.shared.tsbuildinfo +1 -0
  339. package/tsconfig.tsbuildinfo +1 -0
  340. package/dist-server/engine/board-vocabulary.js.map +0 -1
  341. package/dist-server/engine/entity-delta.js.map +0 -1
  342. package/dist-server/engine/kpi-broadcast.js.map +0 -1
  343. package/dist-server/engine/twin-level.js.map +0 -1
  344. package/dist-server/service/twin-event/backfill-keys.d.ts +0 -11
  345. package/dist-server/service/twin-event/backfill-keys.js +0 -63
  346. package/dist-server/service/twin-event/backfill-keys.js.map +0 -1
  347. package/dist-server/tsconfig.tsbuildinfo +0 -1
  348. package/server/service/twin-event/backfill-keys.ts +0 -72
  349. /package/{dist-server/engine → dist-shared}/kpi-broadcast.d.ts +0 -0
  350. /package/{dist-server/engine → dist-shared}/kpi-broadcast.js +0 -0
  351. /package/{dist-server/engine → dist-shared}/twin-level.d.ts +0 -0
  352. /package/{dist-server/engine → dist-shared}/twin-level.js +0 -0
  353. /package/{server/engine → shared}/kpi-broadcast.ts +0 -0
  354. /package/{server/engine → shared}/twin-level.ts +0 -0
@@ -0,0 +1,110 @@
1
+ /*
2
+ * ★ 인스턴스별 캐시는 **트윈과 함께 사라진다.**
3
+ *
4
+ * ── 왜 이 검사가 필요한가 ─────────────────────────────────────────────────────
5
+ * 엔진은 트윈마다 메모리에 몇 가지를 들고 있다(실측 추정기·구조 리비전·복구 표식). 지움은 저널과
6
+ * 등록부까지 갔는데 **캐시는 남았다** — 삭제된 트윈의 항목이 서버 재기동까지 살아 있었다. 항목 하나가
7
+ * 하루치 KPI 그룹을 물고 있으므로, 트윈을 만들고 지우는 일이 반복되는 현장에서는 그것이 곧 누수다.
8
+ *
9
+ * TTL 은 이것을 막지 못한다: TTL 은 **값이 낡는 것**을 막을 뿐 **없어진 주인**을 지우지 않는다.
10
+ *
11
+ * 그래서 규율은 둘이다.
12
+ * ① 트윈이 사라지면 그 트윈에 대한 메모리를 **전부** 잊는다(정확한 쪽).
13
+ * ② 우리 API 를 지나지 않고 사라지는 길(도메인째 삭제·DB 직접 조작)에는 **상한**이 막는다(방어).
14
+ *
15
+ * 사설(private) 필드를 들여다보는 검사인 것을 안다. 그런데 여기서 지켜야 하는 것은 겉으로 드러나는
16
+ * 값이 아니라 **메모리가 자라지 않는다**는 사실이고, 그것은 안을 봐야 볼 수 있다.
17
+ */
18
+ import { test, before, after } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { createRequire } from 'node:module'
21
+ import type { DataSource } from 'typeorm'
22
+
23
+ const req = createRequire(import.meta.url)
24
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
25
+ const { entities: authEntities } = req('@things-factory/auth-base')
26
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
27
+ const { TwinEngine } = req('../dist-server/engine/index.js')
28
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
29
+
30
+ let ds: DataSource
31
+ let domainId: string
32
+
33
+ const INSTANCE = 'cache-probe'
34
+
35
+ const master = (): any => ({
36
+ source: INSTANCE,
37
+ system: 'wms',
38
+ siteName: '캐시 창고',
39
+ space: { spaceId: 'cache-site', width: 40, depth: 30, unit: 'm' },
40
+ locations: [
41
+ { id: 'dock', type: 'dock', role: 'location', name: '도크', capacity: 50 },
42
+ { id: 'bin-1', type: 'storage', role: 'location', name: '빈1', capacity: 20 }
43
+ ],
44
+ equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }]
45
+ })
46
+
47
+ /** 캐시 안을 본다 — 겉으로는 안 보이는 사실을 지키는 검사이므로. */
48
+ const caches = () => ({
49
+ measured: (TwinEngine as any).measuredCache as Map<string, unknown>,
50
+ structureRev: (TwinEngine as any).structureRevCache as Record<string, number>,
51
+ recovered: (TwinEngine as any).recovered as Record<string, unknown>
52
+ })
53
+ const keyOf = (id: string) => `${domainId}:${id}`
54
+
55
+ before(async () => {
56
+ ds = new TypeOrmDataSource({
57
+ type: 'sqlite',
58
+ database: ':memory:',
59
+ synchronize: true,
60
+ dropSchema: true,
61
+ namingStrategy: new NamingStrategy(),
62
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
63
+ logging: false
64
+ })
65
+ await ds.initialize()
66
+ addDataSource('default', ds)
67
+ const d = await ds.getRepository(Domain).save({ name: 'cache-test', subdomain: 'cache-test' } as any)
68
+ domainId = d.id
69
+ })
70
+
71
+ after(async () => {
72
+ await TwinEngine.stop(domainId, INSTANCE).catch(() => {})
73
+ await ds?.destroy()
74
+ })
75
+
76
+ test('지운 트윈의 캐시는 남지 않는다 — TTL 은 없어진 주인을 지우지 않는다', async () => {
77
+ await TwinEngine.ingestMaster(domainId, master())
78
+ /* 캐시를 실제로 채운다 — 손으로 넣으면 "채우는 경로" 가 바뀌어도 검사가 통과한다. */
79
+ await TwinEngine.structureRevOf(domainId, INSTANCE)
80
+ const { measured, structureRev } = caches()
81
+ assert.ok(keyOf(INSTANCE) in structureRev, '구조 리비전이 캐시에 올라와야 이 검사가 뜻이 있다')
82
+
83
+ await TwinEngine.remove(domainId, INSTANCE)
84
+
85
+ const after = caches()
86
+ assert.equal(keyOf(INSTANCE) in after.structureRev, false, '지운 트윈의 구조 리비전이 남았다')
87
+ assert.equal(after.measured.has(keyOf(INSTANCE)), false, '지운 트윈의 실측 캐시가 남았다')
88
+ assert.equal(keyOf(INSTANCE) in after.recovered, false, '지운 트윈의 복구 표식이 남았다')
89
+ assert.equal(measured, after.measured, '캐시 객체를 갈아치우는 것이 아니라 항목을 지운다')
90
+ })
91
+
92
+ test('상한을 넘으면 가장 오래 손대지 않은 것부터 버린다 — 주인 없이 사라진 트윈까지 막는다', async () => {
93
+ const { measured } = caches()
94
+ measured.clear()
95
+ const MAX = (TwinEngine as any).MEASURED_MAX as number
96
+ assert.ok(MAX > 0, '상한이 선언되어 있어야 방어가 성립한다')
97
+
98
+ /*
99
+ * 상한 + 2 개를 넣고, 넣는 경로(`measuredEstimator`)를 그대로 쓴다. 트윈이 등록되어 있지 않으면
100
+ * KPI 조회가 빈 값을 내고 캐시에는 `undefined` 가 들어간다 — **그 자체도 캐시할 값이다**(없다는
101
+ * 사실을 알고 있는 것과 매번 다시 묻는 것은 다르다).
102
+ */
103
+ const call = (TwinEngine as any).measuredEstimator.bind(TwinEngine)
104
+ for (let i = 0; i < MAX + 2; i++) await call(domainId, `ghost-${i}`)
105
+
106
+ assert.ok(measured.size <= MAX, `상한을 넘어 자랐다(${measured.size} > ${MAX})`)
107
+ assert.equal(measured.has(`${domainId}:ghost-0`), false, '가장 오래된 항목이 남아 있다 — 버릴 순서가 어긋났다')
108
+ assert.ok(measured.has(`${domainId}:ghost-${MAX + 1}`), '가장 최근 항목은 남아야 한다')
109
+ measured.clear()
110
+ })
@@ -0,0 +1,96 @@
1
+ /*
2
+ * 커널 선택 가드 — **모르는 종류로는 뜨지 않는다.**
3
+ *
4
+ * ── 무엇이 있었나 (2026-08-08) ───────────────────────────────────────────────
5
+ * 커널을 고르는 자리 셋이 모두 이랬다:
6
+ *
7
+ * const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel
8
+ *
9
+ * 폴백이 둘 겹쳐 있었다. ①은 컬럼이 비었을 때, ②는 표에 없는 값일 때. 그리고 `kind` 는
10
+ * 검증 없는 자유 문자열이다(`@Arg('kind') kind: string` — enum 도 화이트리스트도 없다).
11
+ * 그래서 오타 하나·대소문자 하나로 **야드/생산 트윈이 조용히 창고 커널로 돌았다.**
12
+ * 오류가 없으니 화면에는 트윈이 정상으로 보이고, 안에서 도는 규칙만 다른 도메인의 것이다.
13
+ * 예측 경로가 가장 나빴다 — 야드의 미래를 창고 규칙으로 굴려 놓고 숫자만 뜬다.
14
+ *
15
+ * ── 왜 검사로 남기나 ────────────────────────────────────────────────────────
16
+ * 자리를 고치는 것만으로는 부족하다. 커널 종류가 늘 때(다음은 TMS 든 무엇이든) 새 자리가
17
+ * 생기고, `?? WmsKernel` 은 "안전한 기본값" 처럼 보여서 다시 자란다. 실제로 세 자리에
18
+ * 똑같이 자라 있었다. 틀린 공장을 조용히 띄우는 것보다 뜨지 않는 편이 낫다.
19
+ *
20
+ * 이 검사가 막는 것은 **표를 밖에서 직접 색인하는 것**이다. 종류→커널 해소는 `kernelFor`
21
+ * 하나만 한다. 새 자리를 쓸 때 이 검사가 걸리면, 폴백을 붙이지 말고 `kernelFor` 를 부를 것.
22
+ */
23
+ import { test } from 'node:test'
24
+ import assert from 'node:assert/strict'
25
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+
28
+ const ENGINE = new URL('../server/engine/twin-engine.ts', import.meta.url).pathname
29
+ const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url).pathname
30
+
31
+ /*
32
+ * 종류 폴백은 **한 패키지에 갇혀 있지 않다.** 처음에 twin-engine 두 자리만 고쳤더니, 라이브 미러를
33
+ * 띄우는 호출자 넷(operato-twin 의 벤치·부하시험·프리셋 프로비저닝, headless 의 reference-live)이
34
+ * `det.kind ?? 'wms'` 로 **엔진이 던지기 전에 먼저 이기고** 있었다. 커넥터 spec 도 마찬가지였다.
35
+ * 그래서 두 패키지의 서버 코드를 함께 훑는다.
36
+ */
37
+ const ROOTS = [new URL('../server', import.meta.url).pathname, new URL('../../operato-twin/server', import.meta.url).pathname]
38
+
39
+ /**
40
+ * 코드만 본다 — 주석은 **과거를 설명하느라 옛 코드를 그대로 인용한다**(바로 위 머리말이 그렇다).
41
+ * 줄 단위로 `*`·`//` 만 걸러내면 여러 줄 주석의 둘째 줄부터 새어 나오므로 블록을 통째로 지운다.
42
+ */
43
+ function codeOf(path: string): string[] {
44
+ return readFileSync(path, 'utf-8')
45
+ .replace(/\/\*[\s\S]*?\*\//g, '') // 블록 주석
46
+ .split('\n')
47
+ .map(l => l.replace(/\/\/.*$/, '')) // 줄 주석
48
+ .filter(l => l.trim())
49
+ }
50
+
51
+ test('커널 폴백이 없다 — 모르는 종류는 창고로 떨어지지 않는다', () => {
52
+ const offenders = codeOf(ENGINE).filter(l => /\?\?\s*WmsKernel|\|\|\s*WmsKernel/.test(l))
53
+ assert.deepEqual(offenders, [], '커널 선택에 폴백이 다시 생겼다 — kernelFor 로 던지게 할 것')
54
+ })
55
+
56
+ test('종류→커널 해소는 kernelFor 한 곳만 한다', () => {
57
+ /* 표를 직접 색인하는 줄. 정의부(`const KERNELS: Record…`)와 kernelFor 내부는 제외. */
58
+ const indexing = codeOf(ENGINE).filter(l => /KERNELS\[/.test(l) && !/const K = KERNELS\[kind\]/.test(l))
59
+ assert.deepEqual(indexing, [], 'KERNELS 를 밖에서 색인하고 있다 — kernelFor(kind) 를 쓸 것')
60
+ })
61
+
62
+ test('kernelFor 는 모르는 종류에 던진다 — 조용히 고르지 않는다', () => {
63
+ const src = readFileSync(ENGINE, 'utf-8')
64
+ const fn = src.slice(src.indexOf('function kernelFor'))
65
+ assert.match(fn.slice(0, 300), /throw new Error/, 'kernelFor 가 던지지 않으면 이 계약이 무너진다')
66
+ })
67
+
68
+ test("board 를 주는 기동은 kind 를 요구한다 — `kind ?? 'wms'` 로 메우지 않는다", () => {
69
+ const offenders = codeOf(MUTATION).filter(l => /kind\s*\?\?\s*['"]/.test(l))
70
+ assert.deepEqual(offenders, [], '종류를 빠뜨린 프로비저닝이 다시 창고로 기동한다')
71
+ })
72
+
73
+ /** 두 패키지의 서버 .ts 전부. */
74
+ function serverFiles(): string[] {
75
+ const out: string[] = []
76
+ const walk = (dir: string) => {
77
+ for (const e of readdirSync(dir)) {
78
+ const p = join(dir, e)
79
+ if (statSync(p).isDirectory()) walk(p)
80
+ else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p)
81
+ }
82
+ }
83
+ for (const r of ROOTS) walk(r)
84
+ return out
85
+ }
86
+
87
+ test('어느 서버 코드도 커널 종류를 리터럴로 메우지 않는다 — 호출자가 엔진의 검증을 앞질렀다', () => {
88
+ /* 예시 JSON 안의 `system: 'wms'` 는 필드를 보여 주는 것이라 대상이 아니다 — 폴백 연산자만 본다. */
89
+ const offenders: string[] = []
90
+ for (const f of serverFiles()) {
91
+ codeOf(f).forEach(l => {
92
+ if (/(\?\?|\|\|)\s*['"](wms|yms|mes)['"]/.test(l)) offenders.push(`${f.split('/packages/')[1]}: ${l.trim().slice(0, 90)}`)
93
+ })
94
+ }
95
+ assert.deepEqual(offenders, [], '종류 폴백이 다시 생겼다 — 선언이 없으면 메우지 말고 던질 것')
96
+ })
@@ -90,8 +90,10 @@ before(async () => {
90
90
  domain: { id: domainId },
91
91
  instanceId: INSTANCE,
92
92
  kind: 'wms',
93
+ realityMode: 'sim-experiment', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */
94
+ purpose: 'operational',
93
95
  status: 'stopped',
94
- board: { locations: [{ id: 'dock', type: 'dock', capacity: 10 }], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }] }
96
+ model: { locations: [{ id: 'dock', type: 'dock', capacity: 10 }], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }] }
95
97
  } as any)
96
98
 
97
99
  /*
@@ -86,7 +86,7 @@ test('분포는 측정된 표본만으로 만들되, **결측 창도 남긴다**
86
86
  assert.deepEqual([row.min, row.median, row.max], [40, 60, 80])
87
87
  })
88
88
 
89
- test('현재 값이 없으면 자리를 말하지 않는다 — 결측을 0 으로 읽으면 최고 성적으로 뒤집힌다', () => {
89
+ test('현재 값이 없으면 순위를 내지 않는다 — 결측을 0 으로 읽으면 최고 성적으로 뒤집힌다', () => {
90
90
  /* 시간 지표는 "낮을수록 좋다" 라서 결측을 0 으로 놓으면 **일이 없던 구간이 1등**이 된다. */
91
91
  const [row] = summarizeBaseline({ 'workTime.p50Ms': SPECS['workTime.p50Ms'] }, {}, [
92
92
  sample(1, { 'workTime.p50Ms': 30_000 }),
@@ -101,7 +101,7 @@ test('현재 값이 없으면 자리를 말하지 않는다 — 결측을 0 으
101
101
  assert.deepEqual([row.min, row.max], [30_000, 40_000])
102
102
  })
103
103
 
104
- test('표본이 둘 미만이면 자리를 말하지 않는다 — 하나를 분포라 부를 수 없다', () => {
104
+ test('표본이 둘 미만이면 순위를 내지 않는다 — 하나를 분포라 부를 수 없다', () => {
105
105
  const one = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [
106
106
  sample(1, { 'throughput.tasks': 40 })
107
107
  ])[0]
@@ -150,7 +150,7 @@ test('중앙값과 같으면 typical — 임의의 임계를 만들지 않는다
150
150
  assert.equal(row.standing, 'typical')
151
151
  })
152
152
 
153
- test('중앙값 양쪽을 방향대로 가른다', () => {
153
+ test('중앙값 양쪽을 방향에 따라 구분한다', () => {
154
154
  const past = [sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 60 }), sample(3, { 'throughput.tasks': 80 })]
155
155
  const spec = { 'throughput.tasks': SPECS['throughput.tasks'] }
156
156
 
@@ -552,7 +552,7 @@ test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없
552
552
  assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초')
553
553
  })
554
554
 
555
- test('미달을 미달이라 말한다', () => {
555
+ test('미달을 미달로 판정한다', () => {
556
556
  const kpi = kpiOf([...doneAtHour('t1', 7)])
557
557
  const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }])
558
558
  assert.equal(v.met, false)
@@ -572,7 +572,7 @@ test('목표가 없으면 판정하지 않는다', () => {
572
572
  assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), [])
573
573
  })
574
574
 
575
- test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 반대로 거짓말한다', () => {
575
+ test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 판정이 반대로 뒤집힌다', () => {
576
576
  const empty = foldKpi([], DAY)
577
577
  /* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면
578
578
  시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 —
@@ -696,3 +696,394 @@ test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않
696
696
  '대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함'
697
697
  )
698
698
  })
699
+
700
+ /*
701
+ * ── 에너지 원단위 (§10 7단계) ───────────────────────────────────────────────
702
+ *
703
+ * 원단위는 분자(전력량)와 분모(산출)를 **같은 창**에서 나눈 값이다. 그래서 별도 질의가 아니라 이 폴드
704
+ * 안에 있다 — 창이 하나뿐이면 어긋날 수 없다.
705
+ *
706
+ * 분자는 저널의 **구간 마감 사건**에서 온다(커널 상태가 아니라 저널이므로 멈춘 트윈·과거 구간에도
707
+ * 답한다). 그 값이 파생임을 `basis` 가 말하고, 못 센 구간 수를 함께 낸다.
708
+ */
709
+ const T = Date.parse('2026-08-14T00:00:00.000Z')
710
+ const q = 15 * 60_000
711
+ const win = (startMs: number, endMs: number, meanKW?: number) => ({
712
+ eventType: 'energy.demand.window',
713
+ eventTime: new Date(endMs).toISOString(),
714
+ payload: { data: { startMs, endMs, ...(meanKW !== undefined ? { meanKW } : {}) } }
715
+ })
716
+ const orderDone = (atMs: number) => ({
717
+ eventType: 'order.status',
718
+ eventTime: new Date(atMs).toISOString(),
719
+ payload: { data: { status: 'completed' } }
720
+ })
721
+
722
+ test('에너지 사건이 없으면 에너지 블록을 내지 않는다 — 없는 것을 만들지 않는다', () => {
723
+ const r = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
724
+ assert.equal(r.energy, undefined)
725
+ })
726
+
727
+ test('구간 마감에서 전력량과 대당 원단위를 낸다 — 같은 창이므로 어긋날 수 없다', () => {
728
+ const r = foldKpi(
729
+ [win(T, T + q, 400), win(T + q, T + 2 * q, 400), orderDone(T + 60_000), orderDone(T + q + 60_000)],
730
+ { fromMs: T, toMs: T + 2 * q }
731
+ )
732
+ assert.equal(r.energy?.kWh, 200, '400kW × 0.25h × 2구간')
733
+ assert.equal(r.energy?.basis, 'mean-kw', '파생임을 밝힌다')
734
+ assert.equal(r.energy?.counted, 2)
735
+ assert.equal(r.energy?.intensity.value, 100, '200kWh ÷ 완료 2건')
736
+ assert.equal(r.energy?.intensity.unit, 'kWh/unit')
737
+ })
738
+
739
+ test('그 창에 완료가 없으면 원단위는 정의되지 않는다 — 전력량은 그대로 낸다', () => {
740
+ const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
741
+ assert.equal(r.energy?.kWh, 100, '전력량은 사실이다')
742
+ assert.equal(r.energy?.intensity.value, null)
743
+ assert.equal(r.energy?.intensity.reason, 'zero-denominator', '무한으로 내지 않는다')
744
+ })
745
+
746
+ test('계측이 끊긴 구간은 세지 않고 몇 개를 못 셌는지 말한다', () => {
747
+ const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q), orderDone(T + 60_000)], { fromMs: T, toMs: T + 2 * q })
748
+ assert.equal(r.energy?.counted, 1)
749
+ assert.equal(r.energy?.skipped, 1, '조용히 빼면 이 합이 그 창의 전부인 줄 읽는다')
750
+ })
751
+
752
+ test('창에 걸친 구간은 세지 않는다 — 부분을 비례로 자르면 추정이 하나 더 생긴다', () => {
753
+ const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q, 400)], { fromMs: T, toMs: T + q + 60_000 })
754
+ assert.equal(r.energy?.counted, 1)
755
+ assert.equal(r.energy?.kWh, 100)
756
+ })
757
+
758
+ /*
759
+ * ── 창의 최대수요 (2026-08-17) ──────────────────────────────────────────────
760
+ *
761
+ * 전력량은 요금의 사용량 부분이고, 최대수요는 **기본요금과 계약 위반**을 정한다. 평균으로 갈음하면
762
+ * 계약을 넘긴 창이 「안 넘겼다」로 보고된다 — 15분 평균 400kW 인 창의 최대가 1,200kW 일 수 있다.
763
+ */
764
+ const winMax = (startMs: number, endMs: number, meanKW: number, maxKW: number) => ({
765
+ eventType: 'energy.demand.window',
766
+ eventTime: new Date(endMs).toISOString(),
767
+ payload: { data: { startMs, endMs, meanKW, maxKW } }
768
+ })
769
+
770
+ test('창의 최대수요는 구간 최대들 중 가장 큰 값이다 — 평균으로 갈음하지 않는다', () => {
771
+ const r = foldKpi([winMax(T, T + q, 400, 620), winMax(T + q, T + 2 * q, 400, 1180)], { fromMs: T, toMs: T + 2 * q })
772
+ assert.equal(r.energy?.kWh, 200)
773
+ assert.equal(r.energy?.peakKW, 1180)
774
+ assert.equal(r.energy?.peakStartMs, T + q, '언제였는지도 답할 수 있어야 한다')
775
+ })
776
+
777
+ test('구간이 최대를 싣지 않으면 최대수요는 없다 — 0 도 평균도 아니다', () => {
778
+ const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
779
+ assert.equal(r.energy?.kWh, 100)
780
+ assert.equal(r.energy?.peakKW, undefined)
781
+ })
782
+
783
+ /*
784
+ * ── 에너지 목표 (2026-08-17) ────────────────────────────────────────────────
785
+ *
786
+ * 값은 폴드가 내고 있었는데 목표를 걸 수 있는 어휘에 없었다 — 그래서 화면은 「1,240kWh」를 보이면서도
787
+ * 그것이 계약 안쪽인지 말하지 못했다. 방향은 셋 다 「낮을수록 좋다」로 못 박혀 있다.
788
+ */
789
+ test('최대수요 목표 — 계약 상한을 넘겼는지 판정한다', () => {
790
+ const kpi = foldKpi([winMax(T, T + q, 400, 1180)], { fromMs: T, toMs: T + q })
791
+ const [v] = judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }])
792
+ assert.equal(v.direction, 'lower', '많이 쓸수록 좋은 에너지 목표는 없다')
793
+ assert.equal(v.actual, 1180)
794
+ assert.equal(v.met, false, '계약 800kW 를 넘겼다')
795
+ })
796
+
797
+ test('전력량·원단위에도 목표를 건다', () => {
798
+ const kpi = foldKpi([win(T, T + q, 400), orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
799
+ const [kwh] = judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }])
800
+ assert.equal(kwh.actual, 100)
801
+ assert.equal(kwh.met, true)
802
+ const [per] = judgeTargets(kpi, [{ metric: 'energy.intensity', target: 80 }])
803
+ assert.equal(per.actual, 100, '100kWh ÷ 완료 1건')
804
+ assert.equal(per.met, false)
805
+ })
806
+
807
+ test('에너지를 못 잰 창은 판정하지 않는다 — 0 으로 읽으면 안 쓴 것이 만점이 된다', () => {
808
+ const kpi = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
809
+ assert.equal(judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }]).length, 0)
810
+ assert.equal(judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }]).length, 0)
811
+ /* 원단위는 분모가 없어 `null` 로 온다 — 그것도 판정 대상이 아니다. */
812
+ const noOrders = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
813
+ assert.equal(noOrders.energy?.intensity.value, null)
814
+ assert.equal(judgeTargets(noOrders, [{ metric: 'energy.intensity', target: 80 }]).length, 0)
815
+ })
816
+
817
+ /*
818
+ * ── 분기별 몫 (2026-08-17) ──────────────────────────────────────────────────
819
+ *
820
+ * 저널의 에너지 사실은 **현장 총량 한 줄**이다. 그래서 자리별 몫은 잰 것이 아니라 **나눈 것**이고,
821
+ * 그 사실을 값과 함께 내야 한다. 이 검사가 지키는 것: 합 보존 · 근거 표시 · 모르는 자리를 지어내지
822
+ * 않기 · 선언이 없으면 분해하지 않기.
823
+ */
824
+ const runs = (taskId: string, resource: string, fromSec: number, toSec: number) => [
825
+ { eventType: 'task.status', eventTime: new Date(T + fromSec * 1000).toISOString(), payload: { data: { taskId, status: 'in-progress', resourceRef: resource } } },
826
+ { eventType: 'task.status', eventTime: new Date(T + toSec * 1000).toISOString(), payload: { data: { taskId, status: 'completed', resourceRef: resource } } }
827
+ ]
828
+
829
+ test('분기별 몫 — 가동시간에 비례해 나누고, 합은 총량을 지킨다', () => {
830
+ /* press 300초 · weld 100초 → 3:1. 총량 100kWh. */
831
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
832
+ const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
833
+ assert.equal(r.energy?.kWh, 100)
834
+ const by = r.energy?.byFeeder ?? []
835
+ assert.equal(by.length, 2)
836
+ const press = by.find(x => x.key === 'feeder-press')!
837
+ const weld = by.find(x => x.key === 'feeder-weld')!
838
+ assert.ok(Math.abs(press.kWh - 75) < 1e-6, `press ${press.kWh}`)
839
+ assert.ok(Math.abs(weld.kWh - 25) < 1e-6, `weld ${weld.kWh}`)
840
+ /* 합 보존 — 이것이 깨지면 어딘가에서 전기가 사라지거나 생겨난 것이고, 그 위 모든 원단위가 거짓이다. */
841
+ const sum = by.reduce((a, x) => a + x.kWh, 0) + (r.energy?.unattributedKWh ?? 0)
842
+ assert.ok(Math.abs(sum - 100) < 1e-6, `합 ${sum}`)
843
+ })
844
+
845
+ test('몫에는 근거가 붙는다 — 여럿을 나눈 것이면 배분이라고 말한다', () => {
846
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
847
+ const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
848
+ const row = r.energy!.byFeeder![0]
849
+ assert.equal(row.basis, 'apportioned')
850
+ assert.equal(row.weightKind, 'runtimeMs')
851
+ assert.ok(row.weightShare > 0)
852
+ })
853
+
854
+ /*
855
+ * 선언된 부하가 **하나뿐인** 현장 — 커널은 「그 계량기는 그것만 잰다」고 읽는다(모델이 그렇게 말했다).
856
+ * 우리가 그것을 배분으로 낙인찍지 않는다: 처음에는 하드코딩으로 그렇게 했고, 그건 커널이 판정한 것을
857
+ * 화면에서 바꿔 말하는 짓이었다. 반대로 「돌아간 자원만 덮는다」고 거짓 선언을 넘기면 여럿인 현장에서도
858
+ * 이 경로로 빠져 배분이 측정으로 보고된다 — 그것이 실제로 처음의 결함이었다.
859
+ */
860
+ test('선언된 부하가 하나면 커널의 판정을 그대로 옮긴다', () => {
861
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
862
+ const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
863
+ assert.equal(r.energy!.byFeeder![0].basis, 'measured')
864
+ })
865
+
866
+ test('돌지 않은 설비는 0kWh 를 받지 않고, 몇 대가 빠졌는지 센다', () => {
867
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
868
+ const r = foldKpi(events, { fromMs: T, toMs: T + q }, {
869
+ resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld', 'paint-1': 'feeder-paint' }
870
+ })
871
+ const keys = (r.energy!.byFeeder ?? []).map(x => x.key)
872
+ assert.deepEqual(keys, ['feeder-press'], '가동 기록이 없는 자리는 행을 만들지 않는다(0 을 지어내지 않는다)')
873
+ assert.equal(r.energy!.excludedResources, 2, '빠진 설비 수를 밝힌다')
874
+ })
875
+
876
+ test('자리를 모르는 자원은 unknown 으로 모인다 — 빼지도, 아무 분기에 넣지도 않는다', () => {
877
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 200), ...runs('t2', 'ghost-1', 0, 200)]
878
+ const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
879
+ const by = r.energy!.byFeeder!
880
+ assert.ok(by.some(x => x.key === 'unknown'), '모르는 자리를 지어내지 않는다')
881
+ const sum = by.reduce((a, x) => a + x.kWh, 0)
882
+ assert.ok(Math.abs(sum - 100) < 1e-6, '모르는 몫까지 합해 총량을 지킨다')
883
+ })
884
+
885
+ test('선언이 없으면 분해하지 않는다 — 자리를 모르는 채 나눈 숫자는 아무 것의 몫도 아니다', () => {
886
+ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
887
+ const r = foldKpi(events, { fromMs: T, toMs: T + q })
888
+ assert.equal(r.energy?.byFeeder, undefined)
889
+ assert.equal(r.energy?.kWh, 100, '총량은 그대로 낸다')
890
+ })
891
+
892
+ test('그 창에 아무도 돌지 않았으면 나누지 않고 이유를 낸다', () => {
893
+ /* 부하가 둘 이상 선언된 현장 — 나눌 대상은 있는데 몫이 없다. */
894
+ const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, {
895
+ resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' }
896
+ })
897
+ assert.equal(r.energy?.byFeeder, undefined, '가동 기록이 없으면 나눌 근거가 없다')
898
+ assert.equal(r.energy?.unattributedReason, 'no-weights')
899
+ assert.equal(r.energy?.unattributedKWh, 100, '나누지 못한 양은 그대로 남는다(0 으로 만들지 않는다)')
900
+ assert.equal(r.energy?.kWh, 100)
901
+ })
902
+
903
+ /*
904
+ * ── 금액 (2026-08-17) ───────────────────────────────────────────────────────
905
+ *
906
+ * 트윈은 「최대수요 328kW」까지 말하고 멈춰 있었다. 피크를 깎는 일이 돈이 되는 이유가 요금의 구조에
907
+ * 있으므로(사용량 + 기본요금) 단가를 선언하면 금액이 나온다. 계산은 커널이 하고 폴드는 나른다 —
908
+ * 이 검사는 **같은 창의 값에서 나오는지**와 **단가가 없을 때 지어내지 않는지**를 본다.
909
+ */
910
+ test('단가를 선언하면 이 창의 금액이 나온다 — 전력량과 최대수요 각각에서', () => {
911
+ const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q }, {
912
+ tariff: { energyChargePerKWh: 0.1, demandChargePerKW: 20, currency: 'USD' }
913
+ })
914
+ assert.equal(r.energy?.kWh, 100)
915
+ assert.equal(r.energy?.peakKW, 1000)
916
+ const c: any = r.energy?.cost
917
+ assert.equal(c.energyCharge, 10, '100kWh × 0.1')
918
+ assert.equal(c.demandCharge, 20_000, '1,000kW × 20')
919
+ assert.equal(c.currency, 'USD')
920
+ assert.equal(c.demandBasis, 'window-peak-as-period-peak', '기본요금의 조건이 값과 함께 온다')
921
+ })
922
+
923
+ test('단가를 선언하지 않으면 금액을 만들지 않는다', () => {
924
+ const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q })
925
+ assert.equal(r.energy?.cost, undefined)
926
+ })
927
+
928
+ test('최대수요를 못 잰 창은 기본요금을 지어내지 않는다', () => {
929
+ const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20, energyChargePerKWh: 0.1 } })
930
+ const c: any = r.energy?.cost
931
+ assert.equal(c.energyCharge, 10)
932
+ assert.equal(c.demandCharge, undefined)
933
+ })
934
+
935
+ test('에너지 사실이 없는 창에는 금액도 없다 — 0 원이라고 말하지 않는다', () => {
936
+ const r = foldKpi([orderDone(T + 1000)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20 } })
937
+ assert.equal(r.energy, undefined)
938
+ })
939
+
940
+ /*
941
+ * ── 깎인 양 (2026-08-17) ────────────────────────────────────────────────────
942
+ *
943
+ * 요금은 계통 수요로 매겨지고, 그 수요를 만든 것은 설비 부하다. 두 수의 간격이 곧 태양광·배터리가
944
+ * 덜어낸 몫이다 — 그것이 피크 셰이빙의 값이다. **같은 구간의 두 수**여야 차이가 뜻을 갖는다.
945
+ */
946
+ const winNet = (startMs: number, endMs: number, meanKW: number, maxKW: number, grossMaxKW?: number) => ({
947
+ eventType: 'energy.demand.window',
948
+ eventTime: new Date(endMs).toISOString(),
949
+ payload: { data: { startMs, endMs, meanKW, maxKW, ...(grossMaxKW !== undefined ? { grossMaxKW } : {}) } }
950
+ })
951
+
952
+ test('피크 구간의 총부하와 깎인 양을 낸다', () => {
953
+ 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 })
954
+ assert.equal(r.energy?.peakKW, 900)
955
+ assert.equal(r.energy?.peakGrossKW, 1200)
956
+ assert.equal(r.energy?.shavedKW, 300, '1,200 − 900 — 그 순간 계통에서 덜어낸 몫')
957
+ })
958
+
959
+ test('깎인 것이 없으면 그 값을 만들지 않는다 — 0 을 성과로 적지 않는다', () => {
960
+ const r = foldKpi([winNet(T, T + q, 300, 400, 400)], { fromMs: T, toMs: T + q })
961
+ assert.equal(r.energy?.peakGrossKW, 400)
962
+ assert.equal(r.energy?.shavedKW, undefined)
963
+ })
964
+
965
+ test('총부하를 싣지 않은 옛 구간은 그대로 — 순수요와 같다고 단정하지 않는다', () => {
966
+ const r = foldKpi([winMax(T, T + q, 400, 900)], { fromMs: T, toMs: T + q })
967
+ assert.equal(r.energy?.peakKW, 900)
968
+ assert.equal(r.energy?.peakGrossKW, undefined)
969
+ assert.equal(r.energy?.shavedKW, undefined)
970
+ })
971
+
972
+ /*
973
+ * ── 분기 계량이 있으면 배분하지 않는다 (2026-08-18) ───────────────────────────
974
+ * 실 게이트웨이는 분기마다 계량기를 보낸다. 그런데 화면은 「나누지 못함」으로 답했다 — 폴드에 가동시간
975
+ * 배분 경로만 있었기 때문이다. 미러 트윈에는 설비 가동 기록조차 없어(원천이 계량만 준다) 그 경로로는
976
+ * 분기별 값이 영원히 나오지 않았다.
977
+ */
978
+ const windowFact = (startMs: number, points: { id: string; kWh: number; root?: boolean }[], meanKW = 400) => ({
979
+ eventType: 'energy.demand.window',
980
+ eventTime: new Date(startMs + 900_000).toISOString(),
981
+ instanceId: 'ems-1',
982
+ payload: {
983
+ data: {
984
+ startMs,
985
+ endMs: startMs + 900_000,
986
+ meanKW,
987
+ maxKW: meanKW,
988
+ samples: 30,
989
+ points: points.map(p => ({ id: p.id, kWh: p.kWh, ...(p.root ? { root: true } : {}) }))
990
+ }
991
+ }
992
+ })
993
+
994
+ test('분기별 값이 계량에서 온다 — 근거는 measured, 가동시간 이야기를 하지 않는다', () => {
995
+ const t0 = Date.parse('2026-08-18T00:00:00.000Z')
996
+ const r = foldKpi([windowFact(t0, [
997
+ { id: 'meter-incoming', kWh: 100, root: true },
998
+ { id: 'meter-a', kWh: 40 },
999
+ { id: 'meter-b', kWh: 30 }
1000
+ ]) as any], { fromMs: t0, toMs: t0 + 900_000 }, {
1001
+ meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1002
+ })
1003
+ const e = r.energy!
1004
+ const byKey = new Map((e.byFeeder ?? []).map(f => [f.key, f]))
1005
+ assert.equal(byKey.get('feeder-a')?.kWh, 40)
1006
+ assert.equal(byKey.get('feeder-a')?.basis, 'measured')
1007
+ assert.equal(byKey.get('feeder-a')?.weightKind, undefined, '나누지 않았으니 무엇으로 나눴다고 말하지 않는다')
1008
+ assert.equal(byKey.get('feeder-b')?.kWh, 30)
1009
+ })
1010
+
1011
+ test('하위 계량이 덮지 않는 나머지를 남긴다 — 어느 분기에 얹지 않는다', () => {
1012
+ const t0 = Date.parse('2026-08-18T01:00:00.000Z')
1013
+ /* 구간 평균 400kW × 0.25h = 100kWh 가 총량이고, 분기 합은 70 이다. */
1014
+ const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 30 }]) as any],
1015
+ { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
1016
+ const e = r.energy!
1017
+ assert.equal(Math.round(e.kWh!), 100)
1018
+ assert.equal(Math.round(e.unattributedKWh!), 30)
1019
+ assert.equal(e.unattributedReason, 'not-submetered', '「선언이 없다」가 아니라 「계량이 덮지 않는다」')
1020
+ const sum = (e.byFeeder ?? []).reduce((s, f) => s + f.kWh, 0) + (e.unattributedKWh ?? 0)
1021
+ assert.ok(Math.abs(sum - e.kWh!) < 1e-6, `합이 보존된다(${sum} vs ${e.kWh})`)
1022
+ })
1023
+
1024
+ test('분기 합이 뿌리를 넘으면 그 사실을 낸다 — 깎아 맞추지 않는다', () => {
1025
+ const t0 = Date.parse('2026-08-18T02:00:00.000Z')
1026
+ const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 90 }, { id: 'meter-b', kWh: 30 }]) as any],
1027
+ { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
1028
+ const e = r.energy!
1029
+ assert.equal(Math.round(e.submeterExcessKWh!), 20, '120 − 100')
1030
+ assert.equal(e.byFeeder?.find(f => f.key === 'feeder-a')?.kWh, 90, '계량값은 그대로 남는다')
1031
+ })
1032
+
1033
+ test('계량 지도가 없으면 예전 배분 경로 그대로 — 거동을 바꾸지 않는다', () => {
1034
+ const t0 = Date.parse('2026-08-18T03:00:00.000Z')
1035
+ const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }]) as any],
1036
+ { fromMs: t0, toMs: t0 + 900_000 }, { resourceFeeder: { 'press-1': 'feeder-a', 'press-2': 'feeder-b' } })
1037
+ const e = r.energy!
1038
+ /* 선언된 부하가 둘인데 가동 기록이 없으니 나눌 몫이 없다 — 총량은 그대로 남고 이유가 붙는다(예전 거동). */
1039
+ assert.equal(e.unattributedReason, 'no-weights')
1040
+ assert.equal(e.byFeeder, undefined, '계량 경로를 타지 않았다')
1041
+ })
1042
+
1043
+ test('피크를 만든 분기를 그 순간의 kW 로 답한다 — 전력량 큰 분기와 다를 수 있다', () => {
1044
+ const t0 = Date.parse('2026-08-18T06:00:00.000Z')
1045
+ const t1 = t0 + 900_000
1046
+ /* 두 구간: 앞 구간이 전력량은 크지만(a 가 꾸준히), 피크는 뒤 구간에서 b 가 만든다. */
1047
+ const w0 = {
1048
+ eventType: 'energy.demand.window',
1049
+ eventTime: new Date(t1).toISOString(),
1050
+ instanceId: 'ems-1',
1051
+ payload: { data: { startMs: t0, endMs: t1, meanKW: 400, maxKW: 500, samples: 30,
1052
+ points: [{ id: 'meter-a', kWh: 80 }, { id: 'meter-b', kWh: 20 }],
1053
+ pointsAtPeak: [{ id: 'meter-a', kW: 450 }, { id: 'meter-b', kW: 50 }] } }
1054
+ }
1055
+ const w1 = {
1056
+ eventType: 'energy.demand.window',
1057
+ eventTime: new Date(t1 + 900_000).toISOString(),
1058
+ instanceId: 'ems-1',
1059
+ payload: { data: { startMs: t1, endMs: t1 + 900_000, meanKW: 300, maxKW: 900, samples: 30,
1060
+ points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 35 }],
1061
+ pointsAtPeak: [{ id: 'meter-a', kW: 300 }, { id: 'meter-b', kW: 600 }] } }
1062
+ }
1063
+ const r = foldKpi([w0, w1] as any, { fromMs: t0, toMs: t1 + 900_000 }, {
1064
+ meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1065
+ })
1066
+ const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
1067
+ assert.equal(byKey.get('feeder-a')?.kWh, 120, '전력량은 a 가 크다')
1068
+ /* 피크 값은 **최대수요를 낸 구간 하나**에서 온다 — 두 구간을 섞으면 다른 순간의 값을 더하는 것이다. */
1069
+ assert.equal(byKey.get('feeder-b')?.peakKW, 600, '피크를 만든 것은 b 다')
1070
+ assert.equal(byKey.get('feeder-a')?.peakKW, 300)
1071
+ })
1072
+
1073
+ test('피크 구간에 표본이 없던 분기는 피크 값을 만들지 않는다', () => {
1074
+ const t0 = Date.parse('2026-08-18T07:00:00.000Z')
1075
+ const w = {
1076
+ eventType: 'energy.demand.window',
1077
+ eventTime: new Date(t0 + 900_000).toISOString(),
1078
+ instanceId: 'ems-1',
1079
+ payload: { data: { startMs: t0, endMs: t0 + 900_000, meanKW: 200, maxKW: 300, samples: 10,
1080
+ points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 10 }],
1081
+ pointsAtPeak: [{ id: 'meter-a', kW: 300 }] } }
1082
+ }
1083
+ const r = foldKpi([w] as any, { fromMs: t0, toMs: t0 + 900_000 }, {
1084
+ meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
1085
+ })
1086
+ const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
1087
+ assert.equal(byKey.get('feeder-a')?.peakKW, 300)
1088
+ assert.equal(byKey.get('feeder-b')?.peakKW, undefined, '그 순간 값을 모르면 만들지 않는다')
1089
+ })