@things-factory/headless-twin 10.0.9 → 10.0.10

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 (353) 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 +125 -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 +46 -14
  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 +112 -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 +52 -15
  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 +150 -0
  263. package/server/service/twin-model/twin-lineage-query.ts +193 -0
  264. package/server/service/twin-model/twin-location.ts +140 -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 +150 -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/command-routing.test.ts +1 -1
  286. package/test/declaration-reaches-model.test.ts +178 -0
  287. package/test/discovery-result.test.ts +1 -1
  288. package/test/energy-topology.test.ts +113 -0
  289. package/test/entity-delta.test.ts +171 -1
  290. package/test/event-time-column.test.ts +67 -0
  291. package/test/forecast-metrics.test.ts +71 -0
  292. package/test/forecast-tuning.test.ts +42 -0
  293. package/test/gap-analytics.test.ts +34 -7
  294. package/test/ingest-bench.test.ts +6 -6
  295. package/test/ingest-running-guard.test.ts +136 -0
  296. package/test/ingest-space.test.ts +29 -1
  297. package/test/instance-cache-lifecycle.test.ts +110 -0
  298. package/test/kernel-kind-guard.test.ts +96 -0
  299. package/test/kpi-baseline-db.test.ts +3 -1
  300. package/test/kpi-baseline.test.ts +3 -3
  301. package/test/kpi-fold.test.ts +393 -2
  302. package/test/kpi-query-bench.test.ts +5 -3
  303. package/test/lineage-survives-restart.test.ts +203 -0
  304. package/test/live-feed-registry.test.ts +67 -0
  305. package/test/live-kernel-facts.test.ts +161 -0
  306. package/test/live-mirror-parity.test.ts +22 -15
  307. package/test/load-meter.test.ts +314 -0
  308. package/test/local-declarations.test.ts +554 -0
  309. package/test/master-to-twin.test.ts +121 -33
  310. package/test/model-basis.test.ts +56 -1
  311. package/test/{board-vocabulary.test.ts → model-vocabulary.test.ts} +25 -25
  312. package/test/move-space.test.ts +149 -0
  313. package/test/mutation-gate.test.ts +19 -5
  314. package/test/oee-accumulator.test.ts +26 -7
  315. package/test/operation-basis.test.ts +100 -0
  316. package/test/operations-capability-db.test.ts +165 -0
  317. package/test/project-structure-db.test.ts +226 -0
  318. package/test/projection-reaches-screen.test.ts +272 -0
  319. package/test/property-effects.test.ts +96 -0
  320. package/test/resync-origin-site.test.ts +77 -0
  321. package/test/scale-twin-bench.test.ts +5 -5
  322. package/test/snapshot-freshness.test.ts +60 -0
  323. package/test/space-integrity.test.ts +223 -0
  324. package/test/standard-coverage.test.ts +94 -0
  325. package/test/state-axes.test.ts +1 -1
  326. package/test/streamline-e2e.test.ts +14 -14
  327. package/test/structure-revision-db.test.ts +11 -11
  328. package/test/tenant-registry-db.test.ts +5 -5
  329. package/test/twin-audit.test.ts +83 -0
  330. package/test/twin-model-item-db.test.ts +275 -0
  331. package/test/twin-model-tree-db.test.ts +176 -0
  332. package/test/twin-origin-resync.test.ts +114 -0
  333. package/test/warm-start-seam.test.ts +6 -6
  334. package/test/warm-start.test.ts +88 -3
  335. package/tsconfig.json +6 -1
  336. package/tsconfig.shared.json +23 -0
  337. package/tsconfig.shared.tsbuildinfo +1 -0
  338. package/tsconfig.tsbuildinfo +1 -0
  339. package/dist-server/engine/board-vocabulary.js.map +0 -1
  340. package/dist-server/engine/entity-delta.js.map +0 -1
  341. package/dist-server/engine/kpi-broadcast.js.map +0 -1
  342. package/dist-server/engine/twin-level.js.map +0 -1
  343. package/dist-server/service/twin-event/backfill-keys.d.ts +0 -11
  344. package/dist-server/service/twin-event/backfill-keys.js +0 -63
  345. package/dist-server/service/twin-event/backfill-keys.js.map +0 -1
  346. package/dist-server/tsconfig.tsbuildinfo +0 -1
  347. package/server/service/twin-event/backfill-keys.ts +0 -72
  348. /package/{dist-server/engine → dist-shared}/kpi-broadcast.d.ts +0 -0
  349. /package/{dist-server/engine → dist-shared}/kpi-broadcast.js +0 -0
  350. /package/{dist-server/engine → dist-shared}/twin-level.d.ts +0 -0
  351. /package/{dist-server/engine → dist-shared}/twin-level.js +0 -0
  352. /package/{server/engine → shared}/kpi-broadcast.ts +0 -0
  353. /package/{server/engine → shared}/twin-level.ts +0 -0
@@ -0,0 +1,118 @@
1
+ /*
2
+ * 정규 인제스트 — **어휘가 둘이다**(물류 EPCIS · 에너지 계측).
3
+ *
4
+ * ── 왜 있나 (2026-08-14, EMS 5단계 준비) ────────────────────────────────────
5
+ * 이 seam 은 무조건 `type: 'ObjectEvent'` 를 만들었다. 그래서 커넥터가 계측을 이 길로 보내면 둘 중
6
+ * 하나가 됐다: `epc` 가 없어 거부되거나(그나마 나은 쪽), **엉뚱한 물품 관측**으로 읽혀 재고가 흔들린다.
7
+ * 오류 없이 틀리는 쪽이 더 나쁘다.
8
+ *
9
+ * 이 시험이 지키는 것 둘:
10
+ * ① 한 배치에 두 어휘가 섞여 와도 각자의 봉투가 된다(커넥터가 나눠 보낼 의무를 지지 않는다).
11
+ * ② **거부분이 사라지지 않는다** — 예전에는 호출부가 `rejected` 를 받지도 않았다.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
17
+
18
+ const NOW = '2026-08-14T09:00:00.000Z'
19
+
20
+ const epcisRecord = {
21
+ epc: 'urn:epc:id:sgtin:0614141.107346.2017',
22
+ action: 'OBSERVE',
23
+ bizStep: 'urn:epcglobal:cbv:bizstep:storing',
24
+ disposition: 'urn:epcglobal:cbv:disp:in_progress',
25
+ readPoint: 'rack-1'
26
+ }
27
+ const energyRecord = { meterId: 'meter-incoming', kW: 1200, at: '2026-08-14T09:00:30.000Z' }
28
+
29
+ test('한 배치에 두 어휘가 섞여도 각자의 봉투가 된다', () => {
30
+ const { accepted, rejected } = ingestCanonicalRecords([epcisRecord, energyRecord] as any, 't', NOW)
31
+ assert.equal(rejected.length, 0, `거부 없음이어야 한다: ${JSON.stringify(rejected)}`)
32
+ const types = accepted.map(e => e.eventType).sort()
33
+ assert.deepEqual(types, ['energy.measured', 'epcis.ObjectEvent'])
34
+ })
35
+
36
+ test('계측을 물품 관측으로 만들지 않는다 — 이것이 이 갈림의 이유다', () => {
37
+ const { accepted } = ingestCanonicalRecords([energyRecord] as any, 't', NOW)
38
+ assert.equal(accepted.length, 1)
39
+ assert.equal(accepted[0].eventType, 'energy.measured')
40
+ /* EPCIS 페이로드의 흔적이 없어야 한다 — 있으면 미러가 물품을 지어낸다. */
41
+ assert.equal((accepted[0].data as any).epcList, undefined)
42
+ assert.equal((accepted[0].data as any).type, undefined)
43
+ })
44
+
45
+ test('거부분이 사라지지 않는다 — 커넥터 매핑이 틀린 것을 알 수 있어야 한다', () => {
46
+ const { accepted, rejected } = ingestCanonicalRecords(
47
+ [
48
+ energyRecord,
49
+ { meterId: 'm-2', kW: 10, at: '어제' }, // 시각 형식 오류
50
+ { epc: '', action: 'OBSERVE', bizStep: 'x' } // EPCIS 쪽 결손
51
+ ] as any,
52
+ 't',
53
+ NOW
54
+ )
55
+ assert.equal(accepted.length, 1, '유효한 것은 통과한다')
56
+ assert.equal(rejected.length, 2)
57
+ for (const r of rejected) assert.ok(r.errors.length > 0, '이유 없는 거부가 없다')
58
+ })
59
+
60
+ test('빈 입력에서 두 경로 모두 조용하다 — 없는 것을 만들지 않는다', () => {
61
+ const empty = ingestCanonicalRecords([], 't', NOW)
62
+ assert.deepEqual(empty, { accepted: [], rejected: [] })
63
+ const nothing = ingestCanonicalRecords(null, 't', NOW)
64
+ assert.deepEqual(nothing, { accepted: [], rejected: [] })
65
+ })
66
+
67
+ test('시각이 없는 계측은 호출자의 기준 시각을 쓴다 — 도착 시각이 근거다', () => {
68
+ const { accepted } = ingestCanonicalRecords([{ meterId: 'm-1', kW: 5 }] as any, 't', NOW)
69
+ assert.equal(accepted[0].eventTime, NOW)
70
+ })
71
+
72
+ /*
73
+ * ── 세 번째 어휘: 설비 에너지 상태 (2026-08-15) ─────────────────────────────
74
+ * 능력 계약은 태양광·축전지·감축 부하·차단기의 상태 필드를 선언해 두었는데 받는 길이 없었다.
75
+ * 계량과 갈라 두는 이유는 커널에 적혀 있다 — 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다.
76
+ */
77
+ test('설비 에너지 상태는 EPCIS 도 계량도 아닌 자기 어휘로 들어온다', () => {
78
+ const { accepted, rejected } = ingestCanonicalRecords(
79
+ [
80
+ { equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z', generatedKW: 210 },
81
+ { meterId: 'm-1', kW: 800, at: '2026-08-15T09:00:00.000Z' },
82
+ { epc: 'urn:epc:id:sgtin:1.2.3', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing' }
83
+ ] as any,
84
+ 'tenant-1',
85
+ '2026-08-15T09:00:00.000Z'
86
+ )
87
+ assert.equal(rejected.length, 0, JSON.stringify(rejected))
88
+ const types = accepted.map(a => a.eventType).sort()
89
+ assert.deepEqual(types, ['energy.equipment', 'energy.measured', 'epcis.ObjectEvent'].sort(), '세 어휘가 각자 봉투로')
90
+ })
91
+
92
+ test('바꿀 상태가 없는 보고는 거부한다 — 이유와 함께', () => {
93
+ const { accepted, rejected } = ingestCanonicalRecords(
94
+ [{ equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z' }] as any,
95
+ 'tenant-1',
96
+ '2026-08-15T09:00:00.000Z'
97
+ )
98
+ assert.equal(accepted.length, 0)
99
+ assert.match(rejected[0].errors.join(' '), /바꿀 상태/)
100
+ })
101
+
102
+ test('충전율이 백분율 범위를 벗어나면 거부한다 — 단위를 잘못 맞춘 커넥터를 통과시키지 않는다', () => {
103
+ const { rejected } = ingestCanonicalRecords(
104
+ [{ equipmentId: 'ess-1', at: '2026-08-15T09:00:00.000Z', soc: 380 }] as any,
105
+ 'tenant-1',
106
+ '2026-08-15T09:00:00.000Z'
107
+ )
108
+ assert.equal(rejected.length, 1)
109
+ assert.match(rejected[0].errors.join(' '), /soc/)
110
+ })
111
+
112
+ test('개폐 위치는 표준의 네 값만 받는다 — 과도·불량을 열림/닫힘으로 반올림하지 않는다', () => {
113
+ const ok = ingestCanonicalRecords([{ equipmentId: 'cb-1', at: '2026-08-15T09:00:00.000Z', position: 'intermediate' }] as any, 't', '2026-08-15T09:00:00.000Z')
114
+ assert.equal(ok.accepted.length, 1)
115
+ const bad = ingestCanonicalRecords([{ equipmentId: 'cb-1', at: '2026-08-15T09:00:00.000Z', position: 'ON' }] as any, 't', '2026-08-15T09:00:00.000Z')
116
+ assert.equal(bad.rejected.length, 1)
117
+ assert.match(bad.rejected[0].errors.join(' '), /position/)
118
+ })
@@ -9,7 +9,7 @@ import { test } from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
10
 
11
11
  import { DOMAIN_CATALOG, CAPABILITIES, CAPABILITY_KEYS, capabilitiesForType, stateFieldsOf } from '@operato/twin-kernel'
12
- import { buildEntityDeltas } from '../server/engine/entity-delta.ts'
12
+ import { buildEntityDeltas } from '../shared/entity-delta.ts'
13
13
 
14
14
  const SYSTEMS = ['wms', 'yms', 'mes'] as const
15
15
 
@@ -43,7 +43,7 @@ test('없는 인스턴스와 다른 도메인은 같은 사유로 거절한다
43
43
  assert.deepEqual(routeCommand(sim, false, 'attention.ack'), { target: 'reject', errorCode: 'no-instance' })
44
44
  })
45
45
 
46
- test('런타임이 없는 시뮬 — 터뜨리지 않고 이유를 말한다', () => {
46
+ test('런타임이 없는 시뮬 — 예외를 던지지 않고 이유를 알린다', () => {
47
47
  assert.deepEqual(routeCommand({ hasRuntime: false, hasKernelDispatch: true }, true, 'order.hold'), { target: 'reject', errorCode: 'no-runtime' })
48
48
  })
49
49
 
@@ -0,0 +1,178 @@
1
+ /*
2
+ * 선언 적합성(경계 편) — **원본이 말한 사실은 모델까지 간다.**
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
5
+ * 커널 쪽에는 같은 이름의 하네스가 있다(kernel/test/declaration-in-state: 선언 ⊆ 상태). 그런데 사실이
6
+ * 새던 자리는 그 앞에도 있었다: **레퍼런스 마스터 → 커널 모델** 이라는 문 하나(`masterToTwin`).
7
+ *
8
+ * 여기서 새면 아무도 모른다. 원본은 말했고, 화면은 조용히 id 를 보인다. 실제로 그렇게 잃었다 —
9
+ * 자리의 이름(화면이 `cut-station` 을 찍었다), 사람의 이름, GS1 참조, 자원 속성, 시험 명세 참조,
10
+ * 사람·설비의 시험 결과. 하루 사이에 여섯 번이고, 전부 **한 줄에서 필드 하나가 빠진** 것이었다.
11
+ *
12
+ * 타입 검사는 못 잡는다: 필드가 거의 다 선택(optional)이라 **안 옮기는 것이 합법**이다.
13
+ *
14
+ * ── 무엇을 검사하나 ─────────────────────────────────────────────────────────
15
+ * `RefLocation`·`RefEquipment`·`RefPerson`·`RefAsset` 의 **모든 필드**가 셋 중 하나여야 한다:
16
+ *
17
+ * 1. 같은 이름으로 모델에 있다(대부분).
18
+ * 2. `RENAMED` 에 적힌 다른 이름으로 모델에 있다(`homeLocationId → homeLocation`).
19
+ * 3. `ELSEWHERE` 에 **다른 길이 무엇인지 적혀** 있다(좌표는 `layout` 으로, 역할은 분기에 쓰인다).
20
+ *
21
+ * 셋 중 어느 것도 아니면 실패한다 — 새 필드를 추가한 사람이 그때 분류해야 한다. 분류를 강제하는 것이
22
+ * 이 검사의 전부다: 잊는 것이 기본값이면 잊는다.
23
+ *
24
+ * 필드 목록은 **소스에서 읽는다**(손으로 적지 않는다). 손으로 적으면 새 필드가 조용히 빠지고, 그것이
25
+ * 바로 이 검사가 막으려는 실패다.
26
+ */
27
+ import { test } from 'node:test'
28
+ import assert from 'node:assert/strict'
29
+ import { readFileSync } from 'node:fs'
30
+
31
+ import { masterToTwin } from '../server/service/reference/reference-master.ts'
32
+
33
+ /*
34
+ * 주석을 먼저 걷어낸다 — **주석 속 예시가 필드로 세어졌다.**
35
+ * 문서에 `effectivity: 'not-yet' | 'expired'` 라고 적어 둔 한 줄 때문에 없는 필드가 검사 대상에 들어왔다.
36
+ * 검사가 없는 것을 요구하면 그 검사는 유지되지 않는다(사람이 예외를 늘려 입을 막는다).
37
+ */
38
+ const SOURCE = readFileSync(new URL('../server/service/reference/reference-master.ts', import.meta.url), 'utf8')
39
+ .replace(/\/\*[\s\S]*?\*\//g, '')
40
+ .replace(/\/\/[^\n]*/g, '')
41
+
42
+ /** `export interface X { … }` 의 깊이 1 필드 이름 — 중첩 객체의 안쪽은 세지 않는다. */
43
+ function fieldsOf(name: string): string[] {
44
+ const start = SOURCE.indexOf(`export interface ${name}`)
45
+ assert.ok(start >= 0, `선언 인터페이스를 못 찾았다: ${name} — 모양이 바뀌면 이 하네스도 따라가야 한다`)
46
+ const body = SOURCE.slice(SOURCE.indexOf('{', start))
47
+ const block = body.slice(0, body.indexOf('\n}') + 2)
48
+ const out: string[] = []
49
+ let depth = 0
50
+ let token = ''
51
+ for (const ch of block) {
52
+ if (ch === '{') { depth++; token = ''; continue }
53
+ if (ch === '}') { depth--; token = ''; continue }
54
+ if (depth === 1 && /[A-Za-z0-9_?]/.test(ch)) { token += ch; continue }
55
+ if (depth === 1 && ch === ':') { if (token) out.push(token.replace('?', '')); token = ''; continue }
56
+ token = ''
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** 경계에서 이름이 바뀌는 필드 — 바뀌는 이유는 커널 어휘를 따르기 때문이다(호스트 어휘를 밀어넣지 않는다). */
62
+ const RENAMED: Record<string, string> = { homeLocationId: 'homeLocation' }
63
+
64
+ /**
65
+ * **다른 길로 가는 사실** — 모델의 그 자원 줄에는 없지만 잃지도 않는다. 길을 여기 적는다.
66
+ * 적지 않고 넘기면 "원래 안 가는 것" 과 "새는 것" 이 같아 보인다.
67
+ */
68
+ const ELSEWHERE: Record<string, string> = {
69
+ role: '자리인가 구역인가를 가르는 입력 — 모델의 locations 와 공간의 areas 로 갈라진다(값 자체는 나를 필요가 없다).',
70
+ footprint: '좌표는 `model.layout`(중심점)과 구역 기하로 간다 — 자리 줄에 실리는 값이 아니다.'
71
+ }
72
+
73
+ /** 모든 필드에 값을 실은 원본 — 값을 모르는 필드가 나오면 이 하네스는 실패한다(조용히 건너뛰지 않는다). */
74
+ const VALUE: Record<string, unknown> = {
75
+ id: '__ID__',
76
+ type: 'cut-station',
77
+ kind: 'welder',
78
+ name: '절단 스테이션',
79
+ description: '강판을 자르는 자리',
80
+ role: 'location',
81
+ parentId: 'zone-a',
82
+ /* 전기 상류 — 공간 부모(`parentId`)와 다른 관계다. 값이 모델까지 가는지 이 하네스가 지킨다. */
83
+ upstreamId: 'incoming-1',
84
+ capacity: 40,
85
+ parallelism: 2,
86
+ level: 'WorkCenter',
87
+ footprint: { x: 1, y: 2, w: 3, h: 4 },
88
+ gs1Id: 'urn:epc:id:sgln:0614141.00001.0',
89
+ homeLocationId: 'cut-station',
90
+ mtbfMs: 600000,
91
+ mttrMs: 60000,
92
+ window: { startHour: 6, endHour: 22 },
93
+ workCalendar: [{ id: 'day', entryType: 'production', startTime: '06:00', endTime: '22:00' }],
94
+ properties: [{ id: 'speed', value: '1.2', uom: 'MTS' }],
95
+ testSpecificationIds: ['weld-cert'],
96
+ testResults: [{ specId: 'weld-cert', result: 'pass', expiresAt: '2099-01-01T00:00:00.000Z' }],
97
+ effectiveStart: '2020-01-01T00:00:00.000Z',
98
+ effectiveEnd: '2099-01-01T00:00:00.000Z',
99
+ personnelClassIds: ['welder-op'],
100
+ assetClassIds: ['euro-pallet']
101
+ }
102
+
103
+ interface Case {
104
+ /** 선언 인터페이스 이름 */
105
+ iface: string
106
+ /** 모델의 배열 이름 */
107
+ bucket: 'locations' | 'equipment' | 'persons' | 'assets'
108
+ id: string
109
+ }
110
+
111
+ const CASES: Case[] = [
112
+ { iface: 'RefLocation', bucket: 'locations', id: 'cut-station' },
113
+ { iface: 'RefEquipment', bucket: 'equipment', id: 'cutter-1' },
114
+ { iface: 'RefPerson', bucket: 'persons', id: 'cutter-op-1' },
115
+ { iface: 'RefAsset', bucket: 'assets', id: 'pallet-1' }
116
+ ]
117
+
118
+ /** 그 인터페이스의 모든 필드에 값을 실은 항목. */
119
+ function build(c: Case): any {
120
+ const o: any = {}
121
+ for (const f of fieldsOf(c.iface)) {
122
+ assert.ok(
123
+ f in VALUE,
124
+ `픽스처가 "${c.iface}.${f}" 에 실을 값을 모른다 — VALUE 에 하나 적어라.\n` +
125
+ '조용히 건너뛰면 그 필드는 아무도 안 보는 필드가 되고, 그것이 이 하네스가 막으려는 실패다.'
126
+ )
127
+ o[f] = f === 'id' ? c.id : VALUE[f]
128
+ }
129
+ return o
130
+ }
131
+
132
+ function master(): any {
133
+ return {
134
+ source: 'decl-conformance',
135
+ system: 'mes' as const,
136
+ siteName: '선언 적합성 공장',
137
+ space: { spaceId: 'decl-site', width: 100, depth: 60, unit: 'm' },
138
+ locations: [build(CASES[0])],
139
+ equipment: [build(CASES[1])],
140
+ persons: [build(CASES[2])],
141
+ assets: [build(CASES[3])]
142
+ }
143
+ }
144
+
145
+ test('검사할 필드가 비어 있지 않다 — 비면 이 하네스는 아무것도 지키지 않는다', () => {
146
+ for (const c of CASES) assert.ok(fieldsOf(c.iface).length > 2, `${c.iface}: 필드를 못 읽었다(파서가 낡았다)`)
147
+ })
148
+
149
+ for (const c of CASES) {
150
+ test(`${c.iface} 의 사실이 하나도 잃지 않고 모델까지 간다`, () => {
151
+ const src = build(c)
152
+ const { model } = masterToTwin(master())
153
+ const bucket = (model as any)[c.bucket]
154
+ assert.ok(Array.isArray(bucket), `모델에 ${c.bucket} 이 없다`)
155
+ const got = bucket.find((x: any) => x?.id === c.id)
156
+ assert.ok(got, `선언한 ${c.iface} "${c.id}" 이 모델에 없다 — 사실 하나가 아니라 개체가 사라졌다`)
157
+
158
+ const lost: string[] = []
159
+ const mangled: string[] = []
160
+ for (const f of Object.keys(src)) {
161
+ if (f in ELSEWHERE) continue
162
+ const key = RENAMED[f] ?? f
163
+ if (got[key] === undefined) { lost.push(f); continue }
164
+ try {
165
+ assert.deepEqual(got[key], src[f])
166
+ } catch {
167
+ mangled.push(`${f}→${key}`)
168
+ }
169
+ }
170
+ assert.deepEqual(
171
+ { lost, mangled },
172
+ { lost: [], mangled: [] },
173
+ `${c.iface}: 잃은 사실=[${lost}] · 옮기다 값이 바뀐 사실=[${mangled}]\n` +
174
+ '원본이 말한 것을 문에서 버리면 아무도 모른다 — 화면은 조용히 id 를 보인다.\n' +
175
+ '일부러 다른 길로 보내는 것이면 ELSEWHERE 에 그 길을 적어라(이름만 바뀌는 것은 RENAMED).'
176
+ )
177
+ })
178
+ }
@@ -59,7 +59,7 @@ test('원 시스템이 준 사유를 감추지 않는다 — 사용자가 관리
59
59
  assert.match(e.message, /401 Unauthorized/)
60
60
  })
61
61
 
62
- test('타임아웃은 초 단위로 말한다 — 얼마를 기다렸는지 알 수 있게', () => {
62
+ test('타임아웃은 초 단위로 알린다 — 얼마를 기다렸는지 알 수 있게', () => {
63
63
  const e = discoveryTimedOut('s', 15)
64
64
  assert.equal(e.params?.seconds, 15)
65
65
  assert.match(e.message, /15s/)
@@ -0,0 +1,113 @@
1
+ /*
2
+ * 전력 계통도 — **선언된 것만 그린다.**
3
+ *
4
+ * 이 시험이 지키는 것: 계층·소속이 그림에 그대로 남고, 모르는 것(자리 없는 설비·계량 없는 구간·결선
5
+ * 미상 부하)이 **각자 자기 칸으로** 나온다. 그림에서 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { buildEnergyTopology } from '../server/engine/energy-topology.ts'
11
+
12
+ const plant = () => ({
13
+ locations: [
14
+ { id: 'a-plant', type: 'plant', name: '공장' }, // 공간 부모 — 계통 축이 아니다
15
+ { id: 'incoming-1', type: 'incoming', name: '수전', parentId: 'a-plant' },
16
+ { id: 'feeder-press', type: 'feeder', name: 'Press line', parentId: 'incoming-1' },
17
+ { id: 'feeder-weld', type: 'feeder', name: 'Weld line', parentId: 'incoming-1' },
18
+ { id: 'zone-1', type: 'submeter-zone', name: '도장 구역', parentId: 'feeder-weld' }
19
+ ],
20
+ equipment: [
21
+ { id: 'meter-incoming', kind: 'meter', homeLocation: 'incoming-1' },
22
+ { id: 'meter-press', kind: 'meter', homeLocation: 'feeder-press' },
23
+ { id: 'breaker-1', kind: 'breaker', homeLocation: 'feeder-press' },
24
+ { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1' },
25
+ { id: 'press-1', kind: 'curtailable-load', homeLocation: 'feeder-press' },
26
+ { id: 'load-hvac', kind: 'curtailable-load', homeLocation: 'incoming-1' }, // 결선 미상
27
+ { id: 'ghost', kind: 'curtailable-load', homeLocation: 'nowhere' }
28
+ ]
29
+ })
30
+
31
+ test('계통 계층만 잇는다 — 공간 부모(공장)는 이 축이 아니다', () => {
32
+ const t = buildEnergyTopology(plant())
33
+ assert.equal(t.roots.length, 1)
34
+ assert.equal(t.roots[0].id, 'incoming-1', '수전이 뿌리다(공장 아래에 있어도)')
35
+ assert.deepEqual(t.roots[0].children.map(c => c.id), ['feeder-press', 'feeder-weld'])
36
+ assert.deepEqual(t.roots[0].children[1].children.map(c => c.id), ['zone-1'], '구역은 그 분기 아래')
37
+ })
38
+
39
+ test('계량기는 「이 구간을 잰다」로, 계통 설비는 따로, 부하는 부하로 — 섞지 않는다', () => {
40
+ const t = buildEnergyTopology(plant())
41
+ const press = t.roots[0].children[0]
42
+ assert.deepEqual(press.meters, ['meter-press'])
43
+ assert.deepEqual(press.gear.map(g => g.kind), ['breaker'])
44
+ assert.deepEqual(press.loads.map(l => l.id), ['press-1'])
45
+ assert.deepEqual(t.roots[0].gear.map(g => g.kind), ['battery'], '축전지는 부하가 아니다')
46
+ })
47
+
48
+ test('자리를 모르는 설비를 그림에서 지우지 않는다', () => {
49
+ const t = buildEnergyTopology(plant())
50
+ assert.deepEqual(t.unplaced.map(u => u.id), ['ghost'])
51
+ })
52
+
53
+ test('계량 없는 구간과 결선 미상 부하를 센다 — 「어디에 더 달아야 하나」의 답', () => {
54
+ const t = buildEnergyTopology(plant())
55
+ assert.deepEqual(t.unmetered.sort(), ['feeder-weld', 'zone-1'])
56
+ assert.equal(t.loadsOnIncoming, 1, '수전에 직결된 부하 — 어느 분기가 주는지 모르는 것들')
57
+ })
58
+
59
+ test('폴드가 낸 몫을 노드에 얹는다 — 값을 다시 계산하지 않는다', () => {
60
+ const t = buildEnergyTopology(plant(), {
61
+ byFeeder: [
62
+ { key: 'feeder-press', kWh: 124, basis: 'measured' },
63
+ { key: 'feeder-weld', kWh: 103, basis: 'apportioned' }
64
+ ]
65
+ })
66
+ const [press, weld] = t.roots[0].children
67
+ assert.equal(press.kWh, 124)
68
+ assert.equal(press.basis, 'measured')
69
+ assert.equal(weld.basis, 'apportioned')
70
+ assert.equal(t.roots[0].kWh, undefined, '뿌리의 몫은 분기 표에 없다 — 지어내지 않는다')
71
+ })
72
+
73
+ test('에너지 자리가 없으면 그림을 만들지 않는다 — 빈 그림은 고장으로 읽힌다', () => {
74
+ const t = buildEnergyTopology({ locations: [{ id: 'dock-1', type: 'dock' }], equipment: [] })
75
+ assert.equal(t.roots.length, 0)
76
+ })
77
+
78
+ /*
79
+ * ── 발전·저장의 **지금 값**을 기호 옆에 (2026-08-18) ─────────────────────────
80
+ * 원천이 태양광 발전량과 축전지 충전율·방전을 보내 주는데도 그림에는 기호만 있었다 — 화면이 아는 사실을
81
+ * 그림이 감추는 것이다. 다만 지금 값은 구간 값과 성격이 다르므로 시각을 함께 나른다(계측이 끊기면
82
+ * 멈춘 값을 지금 값으로 그리게 된다).
83
+ */
84
+ const withSources = () => ({
85
+ locations: [{ id: 'incoming-1', type: 'incoming', name: '수전' }],
86
+ equipment: [
87
+ { id: 'meter-incoming', kind: 'meter', homeLocation: 'incoming-1' },
88
+ { id: 'pv-roof', kind: 'pv-array', homeLocation: 'incoming-1' },
89
+ { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1' }
90
+ ]
91
+ })
92
+
93
+ test('종류가 아는 필드만 옮긴다 — 태양광에 충전율을 붙이지 않는다', () => {
94
+ const t = buildEnergyTopology(withSources(), undefined, [
95
+ { id: 'pv-roof', kind: 'pv-array', generatedKW: 16, exportKW: 0, soc: 99, measuredAt: '2026-08-18T09:00:00.000Z' },
96
+ { id: 'ess-1', kind: 'battery', soc: 48, dischargeKW: 200, chargeKW: 0, generatedKW: 7, measuredAt: '2026-08-18T09:00:00.000Z' }
97
+ ])
98
+ const gear = new Map(t.roots[0].gear.map(g => [g.id, g]))
99
+ assert.deepEqual(gear.get('pv-roof')?.live, { generatedKW: 16, exportKW: 0, measuredAt: '2026-08-18T09:00:00.000Z' })
100
+ assert.deepEqual(gear.get('ess-1')?.live, { soc: 48, dischargeKW: 200, chargeKW: 0, measuredAt: '2026-08-18T09:00:00.000Z' })
101
+ })
102
+
103
+ test('상태를 주지 않으면 값을 만들지 않는다 — 구조만 그린다', () => {
104
+ const t = buildEnergyTopology(withSources())
105
+ for (const g of t.roots[0].gear) assert.equal(g.live, undefined)
106
+ })
107
+
108
+ test('보내지 않은 필드는 없다 — 0 으로 채우지 않는다', () => {
109
+ const t = buildEnergyTopology(withSources(), undefined, [{ id: 'ess-1', kind: 'battery', soc: 30 }])
110
+ const ess = t.roots[0].gear.find(g => g.id === 'ess-1')
111
+ assert.deepEqual(ess?.live, { soc: 30 })
112
+ assert.equal(ess?.live?.dischargeKW, undefined, '방전을 보내지 않았으면 모르는 것이다')
113
+ })
@@ -11,7 +11,7 @@
11
11
  import { test } from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
 
14
- import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, itemsTag, ordersTag, personsTag, tasksTag } from '../server/engine/entity-delta.ts'
14
+ import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, itemsTag, ordersTag, personsTag, tasksTag, energyTag } from '../shared/entity-delta.ts'
15
15
 
16
16
  const byTag = (arr: any[], tag: string) => arr.find(x => x.tag === tag)
17
17
 
@@ -180,3 +180,173 @@ test('사람 집약 태그가 항상 나간다 — 선언하지 않은 트윈은
180
180
  const withTwo = buildEntityDeltas({ persons: [{ id: 'w1' }, { id: 'w2' }] }, 't1')
181
181
  assert.equal(withTwo.find(d => d.tag === personsTag('t1'))!.data.persons.length, 2)
182
182
  })
183
+
184
+ /*
185
+ * ── 에너지 (EMS 6단계) ──────────────────────────────────────────────────────
186
+ *
187
+ * 계측은 `state.energy` 에 산다 — 자원 축이 아니다. 이 매핑이 없으면 보드에 놓은 계량기가 값을
188
+ * 받지 못하고 **영원히 빈 모습**이 된다(컴포넌트는 있는데 데이터가 없는, 눈에 안 보이는 결함).
189
+ */
190
+ test('계량 지점의 마지막 관측이 그 설비 태그로 나간다', () => {
191
+ const deltas = buildEntityDeltas(
192
+ {
193
+ locations: [],
194
+ equipment: [],
195
+ energy: {
196
+ points: [
197
+ { id: 'meter-incoming', kW: 1200.5, kWh: 98_000, powerFactor: 0.95, atMs: Date.parse('2026-08-14T09:01:00.000Z'), samplesInWindow: 3 },
198
+ { id: 'meter-quiet', samplesInWindow: 0 }
199
+ ],
200
+ closed: [],
201
+ closedTotal: 0
202
+ }
203
+ },
204
+ 'ems-1'
205
+ )
206
+ const m = deltas.find(d => d.tag === 'meter-incoming')!
207
+ assert.equal(m.data.entity, 'meter')
208
+ assert.equal(m.data.kW, 1200.5)
209
+ assert.equal(m.data.measuredAt, '2026-08-14T09:01:00.000Z', '시각을 함께 낸다 — 화면이 계측 끊김을 스스로 안다')
210
+
211
+ /* 값이 없는 지점: 키를 넣지 않는다(0 으로 만들면 「전기를 안 썼다」가 된다). */
212
+ const q = deltas.find(d => d.tag === 'meter-quiet')!
213
+ assert.equal('kW' in q.data, false)
214
+ assert.equal(q.data.samplesInWindow, 0, '받은 표본 수는 사실이다')
215
+ })
216
+
217
+ test('수요 구간·피크·계약은 집약 태그로 나간다 — 개체의 사실이 아니다', () => {
218
+ const deltas = buildEntityDeltas(
219
+ {
220
+ locations: [],
221
+ equipment: [],
222
+ energy: {
223
+ points: [],
224
+ contractKW: 4000,
225
+ open: { startMs: 1, endMs: 2, samples: 1, maxKW: 4200 },
226
+ peakSince: { kW: 4200, windowStartMs: 1 },
227
+ closed: Array.from({ length: 20 }, (_, i) => ({ startMs: i, endMs: i + 1, samples: 1, maxKW: 100 + i })),
228
+ closedTotal: 137
229
+ }
230
+ },
231
+ 'ems-1'
232
+ )
233
+ const agg = deltas.find(d => d.tag === energyTag('ems-1'))!
234
+ assert.ok(agg, '집약 태그가 있어야 한다')
235
+ assert.equal(agg.data.contractKW, 4000)
236
+ assert.equal(agg.data.peakSince.kW, 4200)
237
+ /* 하루치를 매 방송에 실으면 채널이 그것으로 찬다 — 최근 것만 내고 전체 수를 함께 낸다. */
238
+ assert.equal(agg.data.recent.length, 8)
239
+ assert.equal(agg.data.closedTotal, 137, '자른 사실을 조용히 두지 않는다')
240
+ })
241
+
242
+ test('에너지가 없는 트윈은 에너지 태그를 내지 않는다 — 없는 것을 만들지 않는다', () => {
243
+ const deltas = buildEntityDeltas({ locations: [], equipment: [] }, 'wms-1')
244
+ assert.equal(deltas.some(d => d.tag === energyTag('wms-1')), false)
245
+ assert.equal(deltas.some(d => d.data?.entity === 'meter'), false)
246
+ })
247
+
248
+ test('자리에도 에너지가 실린다 — 능력이 「잰다」고 말하는데 값이 없던 자리', () => {
249
+ const deltas = buildEntityDeltas(
250
+ {
251
+ locations: [
252
+ { id: 'incoming-1', type: 'incoming' },
253
+ { id: 'feeder-a', type: 'feeder' },
254
+ { id: 'feeder-b', type: 'feeder' }
255
+ ],
256
+ equipment: [
257
+ { id: 'm-main', kind: 'meter', homeLocation: 'incoming-1' },
258
+ { id: 'm-a1', kind: 'meter', homeLocation: 'feeder-a' },
259
+ { id: 'm-a2', kind: 'meter', homeLocation: 'feeder-a' }
260
+ ],
261
+ energy: {
262
+ points: [
263
+ { id: 'm-main', kW: 3800, kWh: 1000, atMs: Date.parse('2026-08-14T09:00:00.000Z'), samplesInWindow: 3 },
264
+ { id: 'm-a1', kW: 1000, atMs: Date.parse('2026-08-14T09:01:00.000Z'), samplesInWindow: 2 },
265
+ { id: 'm-a2', kW: 600, atMs: Date.parse('2026-08-14T09:02:00.000Z'), samplesInWindow: 1 }
266
+ ],
267
+ closed: [],
268
+ closedTotal: 0
269
+ }
270
+ },
271
+ 'ems-1'
272
+ )
273
+
274
+ const incoming = deltas.find(d => d.tag === 'incoming-1')!.data
275
+ assert.equal(incoming.entity, 'location', '자리 payload 를 덮지 않고 합친다')
276
+ assert.equal(incoming.kW, 3800)
277
+ assert.deepEqual(incoming.meters, ['m-main'], '어느 계량기에서 온 수인지 되짚을 수 있어야 한다')
278
+
279
+ /* 자리에 계량기가 둘이면 그 자리의 수는 둘의 합이고, 나이는 **가장 최근**이다. */
280
+ const a = deltas.find(d => d.tag === 'feeder-a')!.data
281
+ assert.equal(a.kW, 1600)
282
+ assert.equal(a.samplesInWindow, 3)
283
+ assert.equal(a.measuredAt, '2026-08-14T09:02:00.000Z')
284
+ assert.deepEqual(a.meters, ['m-a1', 'm-a2'])
285
+
286
+ /* 계량기가 없는 자리는 예전 그대로 — 에너지 키를 만들지 않는다(0 은 「안 썼다」가 된다). */
287
+ const b = deltas.find(d => d.tag === 'feeder-b')!.data
288
+ assert.equal('kW' in b, false)
289
+ assert.equal('meters' in b, false)
290
+ })
291
+
292
+ test('자리를 모르는 계량기는 자리에 실리지 않는다 — 설비 태그로만 나간다', () => {
293
+ const deltas = buildEntityDeltas(
294
+ {
295
+ locations: [{ id: 'incoming-1', type: 'incoming' }],
296
+ equipment: [],
297
+ energy: { points: [{ id: 'm-orphan', kW: 500, samplesInWindow: 1 }], closed: [], closedTotal: 0 }
298
+ },
299
+ 'ems-1'
300
+ )
301
+ assert.equal('kW' in deltas.find(d => d.tag === 'incoming-1')!.data, false)
302
+ assert.equal(deltas.find(d => d.tag === 'm-orphan')!.data.kW, 500, '설비 태그로는 나간다')
303
+ })
304
+
305
+ /*
306
+ * ── 능력 넷의 값이 컴포넌트까지 간다 (2026-08-15) ───────────────────────────
307
+ * 보드에 놓인 컴포넌트는 `data(tag)` 로만 값을 받는다. 커널이 값을 들고 있어도 이 페이로드에 실리지
308
+ * 않으면 화면은 영원히 「—」다 — 실제로 그 부류를 두 번 겪었다(스냅샷 투영이 필드를 빠뜨렸고,
309
+ * 값의 시각을 버렸다).
310
+ *
311
+ * 그래서 **설비 태그의 payload** 를 직접 못 박는다: 값과 그 값의 나이가 함께 실려야 한다.
312
+ */
313
+ test('발전·저장·감축·개폐 값과 그 나이가 설비 태그로 나간다', () => {
314
+ const deltas = buildEntityDeltas(
315
+ {
316
+ locations: [{ id: 'incoming-1', type: 'incoming' }],
317
+ equipment: [
318
+ { id: 'pv-1', kind: 'pv-array', homeLocation: 'incoming-1', status: 'idle', generatedKW: 210, exportKW: 40, measuredAt: '2026-08-15T09:00:00.000Z' },
319
+ { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1', status: 'idle', soc: 62, chargeKW: 0, dischargeKW: 85, measuredAt: '2026-08-15T09:00:00.000Z' },
320
+ { id: 'load-1', kind: 'curtailable-load', homeLocation: 'incoming-1', status: 'busy', curtailable: true, minKW: 30, measuredAt: '2026-08-15T09:00:00.000Z' },
321
+ { id: 'cb-1', kind: 'breaker', homeLocation: 'incoming-1', status: 'idle', position: 'closed', measuredAt: '2026-08-15T09:00:00.000Z' }
322
+ ]
323
+ },
324
+ 'ems-1'
325
+ )
326
+ const by = Object.fromEntries(deltas.map(d => [d.tag, d.data as any]))
327
+
328
+ assert.equal(by['pv-1'].generatedKW, 210)
329
+ assert.equal(by['pv-1'].exportKW, 40)
330
+ assert.equal(by['ess-1'].soc, 62)
331
+ assert.equal(by['ess-1'].dischargeKW, 85)
332
+ assert.equal(by['load-1'].curtailable, true)
333
+ assert.equal(by['load-1'].minKW, 30)
334
+ assert.equal(by['cb-1'].position, 'closed')
335
+ /* 나이가 없으면 화면이 멈춘 값을 지금 값처럼 그린다 — 네 컴포넌트가 모두 이 키를 읽는다. */
336
+ for (const id of ['pv-1', 'ess-1', 'load-1', 'cb-1']) {
337
+ assert.equal(by[id].measuredAt, '2026-08-15T09:00:00.000Z', `${id}: 값의 나이가 함께 가야 한다`)
338
+ }
339
+ })
340
+
341
+ test('값이 없는 설비에는 그 키를 만들지 않는다 — 0 으로 그리면 「안 쓴다」가 된다', () => {
342
+ const deltas = buildEntityDeltas(
343
+ {
344
+ locations: [{ id: 'incoming-1', type: 'incoming' }],
345
+ equipment: [{ id: 'pv-2', kind: 'pv-array', homeLocation: 'incoming-1', status: 'idle' }]
346
+ },
347
+ 'ems-1'
348
+ )
349
+ const pv = deltas.find(d => d.tag === 'pv-2')!.data as any
350
+ assert.equal('generatedKW' in pv, false)
351
+ assert.equal('measuredAt' in pv, false)
352
+ })