simview 4.1.0__tar.gz → 4.2.0__tar.gz

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 (205) hide show
  1. {simview-4.1.0/simview.egg-info → simview-4.2.0}/PKG-INFO +2 -1
  2. {simview-4.1.0 → simview-4.2.0}/README.md +1 -0
  3. {simview-4.1.0 → simview-4.2.0}/pyproject.toml +1 -1
  4. {simview-4.1.0 → simview-4.2.0}/simview/__main__.py +43 -16
  5. {simview-4.1.0 → simview-4.2.0}/simview/merge.py +275 -51
  6. {simview-4.1.0 → simview-4.2.0}/simview/model.py +61 -2
  7. {simview-4.1.0 → simview-4.2.0}/simview/scene.py +9 -0
  8. {simview-4.1.0 → simview-4.2.0}/simview/server.py +11 -2
  9. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/Controls.js +8 -3
  10. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/ErrorMetrics.js +48 -20
  11. {simview-4.1.0 → simview-4.2.0/simview.egg-info}/PKG-INFO +2 -1
  12. {simview-4.1.0 → simview-4.2.0}/simview.egg-info/SOURCES.txt +1 -0
  13. simview-4.2.0/tests/test_batch_selection.py +73 -0
  14. {simview-4.1.0 → simview-4.2.0}/tests/test_cli.py +103 -0
  15. {simview-4.1.0 → simview-4.2.0}/tests/test_merge.py +230 -0
  16. {simview-4.1.0 → simview-4.2.0}/tests/test_roundtrip.py +62 -0
  17. {simview-4.1.0 → simview-4.2.0}/tests/test_server.py +17 -0
  18. {simview-4.1.0 → simview-4.2.0}/LICENSE +0 -0
  19. {simview-4.1.0 → simview-4.2.0}/MANIFEST.in +0 -0
  20. {simview-4.1.0 → simview-4.2.0}/setup.cfg +0 -0
  21. {simview-4.1.0 → simview-4.2.0}/simview/__init__.py +0 -0
  22. {simview-4.1.0 → simview-4.2.0}/simview/columnar.py +0 -0
  23. {simview-4.1.0 → simview-4.2.0}/simview/diff.py +0 -0
  24. {simview-4.1.0 → simview-4.2.0}/simview/info.py +0 -0
  25. {simview-4.1.0 → simview-4.2.0}/simview/launcher.py +0 -0
  26. {simview-4.1.0 → simview-4.2.0}/simview/live.py +0 -0
  27. {simview-4.1.0 → simview-4.2.0}/simview/py.typed +0 -0
  28. {simview-4.1.0 → simview-4.2.0}/simview/remote.py +0 -0
  29. {simview-4.1.0 → simview-4.2.0}/simview/render.py +0 -0
  30. {simview-4.1.0 → simview-4.2.0}/simview/state.py +0 -0
  31. {simview-4.1.0 → simview-4.2.0}/simview/static/css/controls.css +0 -0
  32. {simview-4.1.0 → simview-4.2.0}/simview/static/js/SimView.js +0 -0
  33. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/AnimationController.js +0 -0
  34. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/BatchManager.js +0 -0
  35. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/InteractionController.js +0 -0
  36. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/InteractionControls.js +0 -0
  37. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/Scene.js +0 -0
  38. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/StateStore.js +0 -0
  39. {simview-4.1.0 → simview-4.2.0}/simview/static/js/components/WindowedField.js +0 -0
  40. {simview-4.1.0 → simview-4.2.0}/simview/static/js/config.js +0 -0
  41. {simview-4.1.0 → simview-4.2.0}/simview/static/js/main.js +0 -0
  42. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/Body.js +0 -0
  43. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/StaticObject.js +0 -0
  44. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/Terrain.js +0 -0
  45. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/colormap.js +0 -0
  46. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/similarity.js +0 -0
  47. {simview-4.1.0 → simview-4.2.0}/simview/static/js/objects/utils.js +0 -0
  48. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
  49. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/BatchLegend.js +0 -0
  50. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/BodyStateWindow.js +0 -0
  51. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/Legend.js +0 -0
  52. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/PlaybackControls.js +0 -0
  53. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/ScalarPlotter.js +0 -0
  54. {simview-4.1.0 → simview-4.2.0}/simview/static/js/ui/TerrainProfile.js +0 -0
  55. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/batchPresets.js +0 -0
  56. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/batchVisibility.js +0 -0
  57. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/blobCodec.js +0 -0
  58. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/blobWindow.js +0 -0
  59. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/bodyTransforms.js +0 -0
  60. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/csv.js +0 -0
  61. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/episodes.js +0 -0
  62. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/errorMath.js +0 -0
  63. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/injectStyles.js +0 -0
  64. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/interpolate.js +0 -0
  65. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/liveFollow.js +0 -0
  66. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/loadRecordingLibs.js +0 -0
  67. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/terrainSample.js +0 -0
  68. {simview-4.1.0 → simview-4.2.0}/simview/static/js/utils/viewState.js +0 -0
  69. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/index.min.js +0 -0
  70. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/Color.js +0 -0
  71. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/chroma.js +0 -0
  72. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +0 -0
  73. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +0 -0
  74. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/average.js +0 -0
  75. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +0 -0
  76. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +0 -0
  77. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +0 -0
  78. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +0 -0
  79. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/random.js +0 -0
  80. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +0 -0
  81. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +0 -0
  82. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +0 -0
  83. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +0 -0
  84. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +0 -0
  85. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +0 -0
  86. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +0 -0
  87. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +0 -0
  88. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +0 -0
  89. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +0 -0
  90. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +0 -0
  91. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +0 -0
  92. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +0 -0
  93. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +0 -0
  94. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +0 -0
  95. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +0 -0
  96. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +0 -0
  97. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +0 -0
  98. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +0 -0
  99. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +0 -0
  100. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +0 -0
  101. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +0 -0
  102. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +0 -0
  103. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +0 -0
  104. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +0 -0
  105. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +0 -0
  106. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +0 -0
  107. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +0 -0
  108. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +0 -0
  109. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +0 -0
  110. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +0 -0
  111. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +0 -0
  112. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +0 -0
  113. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +0 -0
  114. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +0 -0
  115. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +0 -0
  116. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +0 -0
  117. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +0 -0
  118. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +0 -0
  119. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +0 -0
  120. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +0 -0
  121. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/input.js +0 -0
  122. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +0 -0
  123. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +0 -0
  124. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +0 -0
  125. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +0 -0
  126. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +0 -0
  127. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +0 -0
  128. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +0 -0
  129. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +0 -0
  130. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +0 -0
  131. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +0 -0
  132. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +0 -0
  133. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +0 -0
  134. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +0 -0
  135. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +0 -0
  136. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +0 -0
  137. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +0 -0
  138. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +0 -0
  139. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +0 -0
  140. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +0 -0
  141. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +0 -0
  142. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +0 -0
  143. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +0 -0
  144. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +0 -0
  145. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +0 -0
  146. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +0 -0
  147. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +0 -0
  148. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +0 -0
  149. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/get.js +0 -0
  150. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +0 -0
  151. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +0 -0
  152. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +0 -0
  153. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +0 -0
  154. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/set.js +0 -0
  155. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +0 -0
  156. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +0 -0
  157. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +0 -0
  158. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +0 -0
  159. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +0 -0
  160. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +0 -0
  161. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +0 -0
  162. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/index.js +0 -0
  163. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/last.js +0 -0
  164. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +0 -0
  165. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +0 -0
  166. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +0 -0
  167. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/type.js +0 -0
  168. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +0 -0
  169. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +0 -0
  170. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/chroma-js-3.1.2/src/version.js +0 -0
  171. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/download.js +0 -0
  172. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/js-colormaps.js +0 -0
  173. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/tar.js +0 -0
  174. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  175. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  176. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  177. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  178. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/uPlot.esm.js +0 -0
  179. {simview-4.1.0 → simview-4.2.0}/simview/static/lib/uPlot.min.css +0 -0
  180. {simview-4.1.0 → simview-4.2.0}/simview/static/textures/contacts/red-cross0.png +0 -0
  181. {simview-4.1.0 → simview-4.2.0}/simview/static/textures/points/ball0.png +0 -0
  182. {simview-4.1.0 → simview-4.2.0}/simview/static/textures/points/ball1.png +0 -0
  183. {simview-4.1.0 → simview-4.2.0}/simview/templates/index.html +0 -0
  184. {simview-4.1.0 → simview-4.2.0}/simview/terrain.py +0 -0
  185. {simview-4.1.0 → simview-4.2.0}/simview/utils.py +0 -0
  186. {simview-4.1.0 → simview-4.2.0}/simview.egg-info/dependency_links.txt +0 -0
  187. {simview-4.1.0 → simview-4.2.0}/simview.egg-info/entry_points.txt +0 -0
  188. {simview-4.1.0 → simview-4.2.0}/simview.egg-info/requires.txt +0 -0
  189. {simview-4.1.0 → simview-4.2.0}/simview.egg-info/top_level.txt +0 -0
  190. {simview-4.1.0 → simview-4.2.0}/tests/test_columnar_ondisk.py +0 -0
  191. {simview-4.1.0 → simview-4.2.0}/tests/test_columnar_states.py +0 -0
  192. {simview-4.1.0 → simview-4.2.0}/tests/test_contacts.py +0 -0
  193. {simview-4.1.0 → simview-4.2.0}/tests/test_diff.py +0 -0
  194. {simview-4.1.0 → simview-4.2.0}/tests/test_episodes.py +0 -0
  195. {simview-4.1.0 → simview-4.2.0}/tests/test_info.py +0 -0
  196. {simview-4.1.0 → simview-4.2.0}/tests/test_launcher.py +0 -0
  197. {simview-4.1.0 → simview-4.2.0}/tests/test_live.py +0 -0
  198. {simview-4.1.0 → simview-4.2.0}/tests/test_pointcloud_features.py +0 -0
  199. {simview-4.1.0 → simview-4.2.0}/tests/test_remote.py +0 -0
  200. {simview-4.1.0 → simview-4.2.0}/tests/test_scene.py +0 -0
  201. {simview-4.1.0 → simview-4.2.0}/tests/test_show.py +0 -0
  202. {simview-4.1.0 → simview-4.2.0}/tests/test_terrain.py +0 -0
  203. {simview-4.1.0 → simview-4.2.0}/tests/test_terrain_query.py +0 -0
  204. {simview-4.1.0 → simview-4.2.0}/tests/test_trajectory.py +0 -0
  205. {simview-4.1.0 → simview-4.2.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: THREE.js-based simulation visualization tool
5
5
  Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -63,6 +63,7 @@ Want to try it without installing anything? There's a
63
63
  - **Python API**: Easy-to-use API for generating scenes and launching the visualizer directly from your code.
64
64
  - **JSON Support**: Load and save simulation data using a portable JSON format.
65
65
  - **Remote Files**: Open a scene straight off a compute host with `simview host:path/to/scene.json` — fetched over SSH, compressed on the wire, and cached locally.
66
+ - **Comparing Runs**: Merge several scene files into one, taking whole files or just picked batches — `simview gt.json method_a.json#1 method_b.json#1`.
66
67
 
67
68
  ---
68
69
 
@@ -30,6 +30,7 @@ Want to try it without installing anything? There's a
30
30
  - **Python API**: Easy-to-use API for generating scenes and launching the visualizer directly from your code.
31
31
  - **JSON Support**: Load and save simulation data using a portable JSON format.
32
32
  - **Remote Files**: Open a scene straight off a compute host with `simview host:path/to/scene.json` — fetched over SSH, compressed on the wire, and cached locally.
33
+ - **Comparing Runs**: Merge several scene files into one, taking whole files or just picked batches — `simview gt.json method_a.json#1 method_b.json#1`.
33
34
 
34
35
  ---
35
36
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simview"
7
- version = "4.1.0"
7
+ version = "4.2.0"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -300,16 +300,22 @@ def run_render(path: Path, args: argparse.Namespace) -> None:
300
300
  sys.exit(1)
301
301
 
302
302
 
303
- def save_merged(paths: list[Path], out_path: Path) -> None:
304
- """Merge `paths` (must be >= 2) and write the result to `out_path`, gzipped
305
- if it ends in .gz, without starting the server."""
306
- if len(paths) < 2:
303
+ def save_merged(
304
+ paths: list[Path],
305
+ out_path: Path,
306
+ selections: list[str | None] | None = None,
307
+ ) -> None:
308
+ """Merge `paths` (must be >= 2, or 1 with a batch selection) and write the
309
+ result to `out_path`, gzipped if it ends in .gz, without starting the
310
+ server."""
311
+ selected = selections is not None and any(s is not None for s in selections)
312
+ if len(paths) < 2 and not selected:
307
313
  logger.error("Error: --save-merged requires at least 2 input files to merge.")
308
314
  sys.exit(1)
309
315
 
310
316
  from simview.merge import merge_simulation_files
311
317
 
312
- merged = merge_simulation_files(paths)
318
+ merged = merge_simulation_files(paths, selections)
313
319
  payload = json.dumps(merged).encode("utf-8")
314
320
  if out_path.suffix == ".gz":
315
321
  payload = gzip.compress(payload, compresslevel=1)
@@ -346,7 +352,11 @@ def build_parser() -> argparse.ArgumentParser:
346
352
  "a PNG screenshot (needs the 'render' extra). Multiple "
347
353
  "visualize-mode files are merged into one scene, each file's "
348
354
  "batches appended as extra batches (e.g. a real-world recording "
349
- "plus a simulated rerun). Any input may instead be an scp-style "
355
+ "plus a simulated rerun). Append '#<batches>' to a file to merge "
356
+ "only some of its batches (e.g. 'run.json#1', 'run.json#0,2-3', "
357
+ "'run.json#-1' or 'run.json#<batch name>'), so a ground truth "
358
+ "shared by several files isn't merged once per file. Any input "
359
+ "may instead be an scp-style "
350
360
  "'host:path' spec (e.g. 'rci:~/results/scene.json'), which is "
351
361
  "fetched over ssh -- compressed on the wire -- into a local cache "
352
362
  "and re-fetched only when the remote file changes."
@@ -674,18 +684,35 @@ def main():
674
684
  clear_cache()
675
685
  return
676
686
 
677
- paths = [_resolve_input(spec, args) for spec in args.inputs]
687
+ # Split "scene.json#1,3" into the file and the batches to take from it
688
+ # before resolving the file, so a remote spec can carry a selection too.
689
+ from simview.merge import split_batch_spec
678
690
 
679
- if args.save_merged:
680
- save_merged(paths, Path(args.save_merged))
681
- return
691
+ try:
692
+ split_inputs = [split_batch_spec(spec) for spec in args.inputs]
693
+ except ValueError as e:
694
+ logger.error("Error: %s", e)
695
+ sys.exit(1)
696
+ paths = [_resolve_input(spec, args) for spec, _ in split_inputs]
697
+ selections: list[str | None] = [selector for _, selector in split_inputs]
682
698
 
683
- SimViewServer.start(
684
- sim_path=paths if len(paths) > 1 else paths[0],
685
- host=args.host,
686
- preferred_port=args.port,
687
- open_browser=not args.no_browser,
688
- )
699
+ try:
700
+ if args.save_merged:
701
+ save_merged(paths, Path(args.save_merged), selections)
702
+ return
703
+
704
+ SimViewServer.start(
705
+ sim_path=paths if len(paths) > 1 else paths[0],
706
+ host=args.host,
707
+ preferred_port=args.port,
708
+ open_browser=not args.no_browser,
709
+ batch_selections=selections,
710
+ )
711
+ except ValueError as e:
712
+ # A bad batch selection ("scene.json#9" on a 2-batch file) is user
713
+ # error, not a crash: report it like any other bad argument.
714
+ logger.error("Error: %s", e)
715
+ sys.exit(1)
689
716
 
690
717
 
691
718
  if __name__ == "__main__":
@@ -10,12 +10,18 @@ Files are not required to share a timeline: the first file's timestamps become
10
10
  the merged timeline, and every other file is resampled onto it by nearest
11
11
  timestamp (zero-order hold, no interpolation). Put the recording you care most
12
12
  about matching frame-for-frame first.
13
+
14
+ Each file may contribute a *subset* of its own batches (see
15
+ `parse_batch_selection`), so comparing several methods that each shipped their
16
+ own copy of a shared ground truth doesn't mean merging that ground truth once
17
+ per file.
13
18
  """
14
19
 
15
20
  import base64
16
21
  import bisect
17
22
  import json
18
23
  import logging
24
+ import re
19
25
  import struct
20
26
  from pathlib import Path
21
27
  from typing import Sequence
@@ -43,6 +49,137 @@ _STATE_FIELD_WIDTHS = {
43
49
  }
44
50
 
45
51
 
52
+ # Separates a file from the batches to take out of it in a CLI input spec
53
+ # ("scene.json#1,3"). Deliberately not ':', which already means "remote host"
54
+ # (see simview.remote).
55
+ _BATCH_SPEC_SEP = "#"
56
+ _INDEX_RE = re.compile(r"^-?\d+$")
57
+ _RANGE_RE = re.compile(r"^(\d+)-(\d+)$")
58
+
59
+
60
+ def split_batch_spec(spec: str) -> tuple[str, str | None]:
61
+ """Split a CLI input like ``scene.json#1,3`` into ``("scene.json", "1,3")``,
62
+ or return ``(spec, None)`` when it names no batch subset.
63
+
64
+ Purely syntactic apart from one filesystem check: an existing local file
65
+ wins over the selector reading (mirroring `remote.is_remote_spec`), so a
66
+ file literally named ``odd#name.json`` still opens as itself.
67
+ """
68
+ if _BATCH_SPEC_SEP not in spec:
69
+ return spec, None
70
+ try:
71
+ if Path(spec).exists():
72
+ return spec, None
73
+ except OSError:
74
+ pass
75
+ head, _, selector = spec.rpartition(_BATCH_SPEC_SEP)
76
+ if not head or not selector:
77
+ raise ValueError(
78
+ f"Malformed batch selection '{spec}'; expected "
79
+ f"'file{_BATCH_SPEC_SEP}<batches>', e.g. 'scene.json{_BATCH_SPEC_SEP}1' "
80
+ f"or 'scene.json{_BATCH_SPEC_SEP}0,2-3'."
81
+ )
82
+ return head, selector
83
+
84
+
85
+ def parse_batch_selection(
86
+ selector: str | Sequence[int],
87
+ batch_size: int,
88
+ batch_names: Sequence[str] | None,
89
+ label: str,
90
+ ) -> list[int]:
91
+ """Resolve one file's batch selector to a list of that file's batch indices.
92
+
93
+ `selector` is either an explicit sequence of indices or a comma-separated
94
+ string whose entries are an index (``2``), a negative index counting from
95
+ the end (``-1``), an inclusive range (``0-3``), or one of the file's own
96
+ `batchNames`. Anything that parses as an index or a range is read as such,
97
+ so a batch *named* "2" can only be selected by its index. The selected
98
+ order is the merged order, and selecting the same batch twice is rejected
99
+ as a typo rather than silently duplicating it.
100
+ """
101
+ if isinstance(selector, str):
102
+ raw: list[int] = []
103
+ for token in selector.split(","):
104
+ token = token.strip()
105
+ if not token:
106
+ raise ValueError(
107
+ f"'{label}': empty entry in batch selection '{selector}'."
108
+ )
109
+ if _INDEX_RE.match(token):
110
+ raw.append(int(token))
111
+ continue
112
+ span = _RANGE_RE.match(token)
113
+ if span:
114
+ start, end = int(span.group(1)), int(span.group(2))
115
+ if end < start:
116
+ raise ValueError(
117
+ f"'{label}': batch range '{token}' ends before it starts."
118
+ )
119
+ raw.extend(range(start, end + 1))
120
+ continue
121
+ names = list(batch_names or [])
122
+ if token in names:
123
+ if names.count(token) > 1:
124
+ raise ValueError(
125
+ f"'{label}': '{token}' names {names.count(token)} of its "
126
+ "batches; select one of them by index instead."
127
+ )
128
+ raw.append(names.index(token))
129
+ continue
130
+ known = (
131
+ f"one of its batch names ({', '.join(names)})"
132
+ if names
133
+ else "a batch name (this file declares no 'batchNames')"
134
+ )
135
+ raise ValueError(
136
+ f"'{label}': '{token}' is not a batch index, an 'a-b' range, or "
137
+ f"{known}."
138
+ )
139
+ else:
140
+ raw = [int(i) for i in selector]
141
+
142
+ resolved: list[int] = []
143
+ for index in raw:
144
+ # Negative indices count from the end, as in Python slicing, so '-1'
145
+ # means "the last batch" without knowing the file's batch count.
146
+ actual = index + batch_size if index < 0 else index
147
+ if not 0 <= actual < batch_size:
148
+ raise ValueError(
149
+ f"'{label}': batch {index} is out of range; the file has "
150
+ f"{batch_size} batch(es) (valid indices 0-{batch_size - 1})."
151
+ )
152
+ resolved.append(actual)
153
+ if not resolved:
154
+ raise ValueError(f"'{label}': batch selection is empty.")
155
+ duplicates = sorted({i for i in resolved if resolved.count(i) > 1})
156
+ if duplicates:
157
+ raise ValueError(
158
+ f"'{label}': batch(es) {', '.join(str(d) for d in duplicates)} "
159
+ "selected more than once."
160
+ )
161
+ return resolved
162
+
163
+
164
+ def _select(rows: Sequence, selection: list[int] | None) -> list:
165
+ """Pick `selection`'s rows out of an already fully expanded per-batch list
166
+ (one row per batch of the source file). A None selection keeps every row,
167
+ which is what every field does when no subset was asked for."""
168
+ if selection is None:
169
+ return list(rows)
170
+ return [rows[i] for i in selection]
171
+
172
+
173
+ def _output_sizes(
174
+ batch_sizes: list[int], selections: list[list[int] | None]
175
+ ) -> list[int]:
176
+ """How many batches each file contributes to the merged scene."""
177
+ return [
178
+ size if selection is None else len(selection)
179
+ for size, selection in zip(batch_sizes, selections)
180
+ ]
181
+
182
+
46
183
  def _decode_b64_floats(value) -> tuple[float, ...] | None:
47
184
  """Decode a binary ``__b64__`` blob to a flat tuple of floats, or return
48
185
  None if `value` isn't such a blob (e.g. it's an already-batched plain
@@ -240,17 +377,23 @@ def _merge_bodies(models: list[dict], labels: list[str]) -> list[dict]:
240
377
  return bodies
241
378
 
242
379
 
243
- def _default_batch_names(paths: Sequence[Path], batch_sizes: list[int]) -> list[str]:
380
+ def _default_batch_names(
381
+ paths: Sequence[Path], batch_sizes: list[int], selections: list[list[int] | None]
382
+ ) -> list[str]:
244
383
  """One name per output batch, derived from the source file it came from.
245
384
  Single-batch files just use the file stem; multi-batch files get an index
246
- suffix so batches from the same file are still distinguishable."""
385
+ suffix so batches from the same file are still distinguishable. The suffix
386
+ is the batch's index *in its source file*, so a selected subset stays
387
+ traceable back to it (batch 2 of a 4-batch 'run.json' is 'run[2]', not
388
+ 'run[0]')."""
247
389
  names = []
248
- for path, batch_size in zip(paths, batch_sizes):
390
+ for path, batch_size, selection in zip(paths, batch_sizes, selections):
249
391
  stem = path.stem
250
392
  if batch_size == 1:
251
393
  names.append(stem)
252
394
  else:
253
- names.extend(f"{stem}[{j}]" for j in range(batch_size))
395
+ indices = range(batch_size) if selection is None else selection
396
+ names.extend(f"{stem}[{j}]" for j in indices)
254
397
  return names
255
398
 
256
399
 
@@ -268,7 +411,10 @@ def _merge_scalar_names(models: list[dict], labels: list[str]) -> list[str]:
268
411
 
269
412
 
270
413
  def _merge_static_objects(
271
- models: list[dict], batch_sizes: list[int], labels: list[str]
414
+ models: list[dict],
415
+ batch_sizes: list[int],
416
+ labels: list[str],
417
+ selections: list[list[int] | None],
272
418
  ) -> list[dict]:
273
419
  first = models[0].get("staticObjects") or []
274
420
  names = [s["name"] for s in first]
@@ -294,14 +440,19 @@ def _merge_static_objects(
294
440
  entry["shape"] = shape
295
441
  else:
296
442
  shapes = []
297
- for model, batch_size, label in zip(models, batch_sizes, labels):
443
+ for model, batch_size, label, selection in zip(
444
+ models, batch_sizes, labels, selections
445
+ ):
298
446
  shapes.extend(
299
- _expand_batched(
300
- model["staticObjects"][idx]["shapes"],
301
- False,
302
- batch_size,
303
- "shapes",
304
- label,
447
+ _select(
448
+ _expand_batched(
449
+ model["staticObjects"][idx]["shapes"],
450
+ False,
451
+ batch_size,
452
+ "shapes",
453
+ label,
454
+ ),
455
+ selection,
305
456
  )
306
457
  )
307
458
  entry["shapes"] = shapes
@@ -310,7 +461,11 @@ def _merge_static_objects(
310
461
 
311
462
 
312
463
  def _merge_embedding(
313
- models: list[dict], batch_sizes: list[int], labels: list[str], resolution: int
464
+ models: list[dict],
465
+ batch_sizes: list[int],
466
+ labels: list[str],
467
+ resolution: int,
468
+ selections: list[list[int] | None],
314
469
  ) -> str | None:
315
470
  """Concatenate the per-batch terrain `embeddingData` of every file into a
316
471
  single ``__b64__`` blob (the encoding the viewer's Terrain.js splits per
@@ -336,7 +491,9 @@ def _merge_embedding(
336
491
 
337
492
  per_file_rows: list[list[list[float]]] = []
338
493
  widths: list[int] = []
339
- for model, value, batch_size, label in zip(models, values, batch_sizes, labels):
494
+ for model, value, batch_size, label, selection in zip(
495
+ models, values, batch_sizes, labels, selections
496
+ ):
340
497
  flat = _decode_b64_floats(value)
341
498
  if flat is None:
342
499
  # Real producers always blob-encode embeddings; a plain list is
@@ -374,6 +531,7 @@ def _merge_embedding(
374
531
  rows = [
375
532
  list(flat[i : i + per_batch]) for i in range(0, len(flat), per_batch)
376
533
  ]
534
+ rows = _select(rows, selection)
377
535
  per_file_rows.append(rows)
378
536
  widths.append(len(rows[0]) // resolution)
379
537
 
@@ -393,7 +551,10 @@ def _merge_embedding(
393
551
 
394
552
 
395
553
  def _merge_terrain(
396
- models: list[dict], batch_sizes: list[int], labels: list[str]
554
+ models: list[dict],
555
+ batch_sizes: list[int],
556
+ labels: list[str],
557
+ selections: list[list[int] | None],
397
558
  ) -> dict:
398
559
  first_terrain = models[0]["terrain"]
399
560
  dims = first_terrain["dimensions"]
@@ -453,9 +614,12 @@ def _merge_terrain(
453
614
  if not items:
454
615
  return None
455
616
  merged = []
456
- for value, batch_size in items:
617
+ for value, batch_size, selection in items:
457
618
  merged.extend(
458
- _decode_per_batch(value, batch_size, resolution, vector_width)
619
+ _select(
620
+ _decode_per_batch(value, batch_size, resolution, vector_width),
621
+ selection,
622
+ )
459
623
  )
460
624
  return merged
461
625
 
@@ -465,7 +629,9 @@ def _merge_terrain(
465
629
  property_min_max: dict[str, tuple[float | None, float | None]] = {
466
630
  name: (None, None) for name in kept_properties
467
631
  }
468
- for model, batch_size, label in zip(models, batch_sizes, labels):
632
+ for model, batch_size, label, selection in zip(
633
+ models, batch_sizes, labels, selections
634
+ ):
469
635
  terrain = model["terrain"]
470
636
  singleton = terrain.get("isSingleton", False)
471
637
  height_data.append(
@@ -474,6 +640,7 @@ def _merge_terrain(
474
640
  terrain["heightData"], singleton, batch_size, "heightData", label
475
641
  ),
476
642
  batch_size,
643
+ selection,
477
644
  )
478
645
  )
479
646
  normals.append(
@@ -482,6 +649,7 @@ def _merge_terrain(
482
649
  terrain["normals"], singleton, batch_size, "normals", label
483
650
  ),
484
651
  batch_size,
652
+ selection,
485
653
  )
486
654
  )
487
655
 
@@ -494,6 +662,7 @@ def _merge_terrain(
494
662
  (
495
663
  _expand_batched(prop["data"], singleton, batch_size, name, label),
496
664
  batch_size,
665
+ selection,
497
666
  )
498
667
  )
499
668
  cur_min, cur_max = property_min_max[name]
@@ -528,7 +697,7 @@ def _merge_terrain(
528
697
  "normals": _concat_lists_or_b64(normals, vector_width=3),
529
698
  "properties": merged_properties,
530
699
  }
531
- embedding = _merge_embedding(models, batch_sizes, labels, resolution)
700
+ embedding = _merge_embedding(models, batch_sizes, labels, resolution, selections)
532
701
  if embedding is not None:
533
702
  merged["embeddingData"] = embedding
534
703
  return merged
@@ -568,10 +737,11 @@ def _merge_states(
568
737
  bodies: list[dict],
569
738
  scalar_names: list[str],
570
739
  labels: list[str],
740
+ selections: list[list[int] | None],
571
741
  ) -> list[dict]:
572
742
  ref_times = [s["time"] for s in states_list[0]]
573
743
  times_by_file = [[s["time"] for s in states] for states in states_list]
574
- total_batches = sum(batch_sizes)
744
+ total_batches = sum(_output_sizes(batch_sizes, selections))
575
745
  lookup_cache: dict = {}
576
746
 
577
747
  merged_states = []
@@ -597,8 +767,8 @@ def _merge_states(
597
767
  }
598
768
  contacts = [] if "contacts" in available else None
599
769
 
600
- for file_idx, (states, batch_size) in enumerate(
601
- zip(states_list, batch_sizes)
770
+ for file_idx, (states, batch_size, selection) in enumerate(
771
+ zip(states_list, batch_sizes, selections)
602
772
  ):
603
773
  state_idx = state_idx_by_file[file_idx]
604
774
  lookup = _state_body_lookup(states, file_idx, state_idx, lookup_cache)
@@ -609,12 +779,15 @@ def _merge_states(
609
779
  f"t={states[state_idx]['time']}."
610
780
  )
611
781
  transform.extend(
612
- _normalize_per_batch(
613
- _decode_state_field(
614
- body_state["bodyTransform"],
615
- _STATE_FIELD_WIDTHS["bodyTransform"],
782
+ _select(
783
+ _normalize_per_batch(
784
+ _decode_state_field(
785
+ body_state["bodyTransform"],
786
+ _STATE_FIELD_WIDTHS["bodyTransform"],
787
+ ),
788
+ batch_size,
616
789
  ),
617
- batch_size,
790
+ selection,
618
791
  )
619
792
  )
620
793
  for attr in attr_values:
@@ -624,11 +797,14 @@ def _merge_states(
624
797
  f"available but is missing it at t={states[state_idx]['time']}."
625
798
  )
626
799
  attr_values[attr].extend(
627
- _normalize_per_batch(
628
- _decode_state_field(
629
- body_state[attr], _STATE_FIELD_WIDTHS[attr]
800
+ _select(
801
+ _normalize_per_batch(
802
+ _decode_state_field(
803
+ body_state[attr], _STATE_FIELD_WIDTHS[attr]
804
+ ),
805
+ batch_size,
630
806
  ),
631
- batch_size,
807
+ selection,
632
808
  )
633
809
  )
634
810
  if contacts is not None:
@@ -637,14 +813,15 @@ def _merge_states(
637
813
  f"'{labels[file_idx]}' body '{name}' declares 'contacts' as "
638
814
  f"available but is missing it at t={states[state_idx]['time']}."
639
815
  )
640
- contacts.extend(body_state["contacts"])
816
+ contacts.extend(_select(body_state["contacts"], selection))
641
817
 
642
818
  if len(transform) != total_batches:
643
819
  raise ValueError(
644
820
  f"Merged 'bodyTransform' for body '{name}' has {len(transform)} "
645
- f"rows; expected {total_batches} (sum of simBatches across "
646
- f"{', '.join(repr(label) for label in labels)}). Check that "
647
- "each file's per-body state rows match its declared simBatches."
821
+ f"rows; expected {total_batches} (sum of the merged batch "
822
+ f"counts across {', '.join(repr(label) for label in labels)}). "
823
+ "Check that each file's per-body state rows match its "
824
+ "declared simBatches."
648
825
  )
649
826
  merged_body = {"name": name, "bodyTransform": transform, **attr_values}
650
827
  if contacts is not None:
@@ -654,8 +831,8 @@ def _merge_states(
654
831
  merged_state = {"time": t, "bodies": merged_bodies}
655
832
  for scalar_name in scalar_names:
656
833
  values = []
657
- for file_idx, (states, batch_size) in enumerate(
658
- zip(states_list, batch_sizes)
834
+ for file_idx, (states, batch_size, selection) in enumerate(
835
+ zip(states_list, batch_sizes, selections)
659
836
  ):
660
837
  state_idx = state_idx_by_file[file_idx]
661
838
  scalar_values = states[state_idx].get(scalar_name)
@@ -664,20 +841,44 @@ def _merge_states(
664
841
  f"'{labels[file_idx]}' is missing scalar '{scalar_name}' at "
665
842
  f"t={states[state_idx]['time']}."
666
843
  )
667
- values.extend(scalar_values)
844
+ values.extend(_select(scalar_values, selection))
668
845
  merged_state[scalar_name] = values
669
846
  merged_states.append(merged_state)
670
847
 
671
848
  return merged_states
672
849
 
673
850
 
674
- def merge_simulation_files(paths: Sequence[str | Path]) -> dict:
851
+ def merge_simulation_files(
852
+ paths: Sequence[str | Path],
853
+ selections: Sequence[str | Sequence[int] | None] | None = None,
854
+ ) -> dict:
675
855
  """Load and merge `paths` into a single `{"model": ..., "states": ...}` dict
676
- where each file's batches are concatenated into the output's batch dimension."""
677
- if len(paths) < 2:
678
- raise ValueError("merge_simulation_files requires at least 2 files")
679
-
856
+ where each file's batches are concatenated into the output's batch dimension.
857
+
858
+ `selections`, if given, must be parallel to `paths`: each entry is None
859
+ (contribute every batch of that file) or a batch selector -- a string like
860
+ ``"1"``, ``"0,2"``, ``"1-3"``, ``"-1"`` or a batch name, or an explicit
861
+ sequence of indices (see `parse_batch_selection`). Selecting a subset is
862
+ what lets several files that each carry the same ground-truth batch be
863
+ merged without duplicating it, and it is the one case where a single file
864
+ is a valid merge input.
865
+ """
680
866
  resolved_paths = [Path(p) for p in paths]
867
+ if selections is None:
868
+ requested: list[str | Sequence[int] | None] = [None] * len(resolved_paths)
869
+ else:
870
+ requested = list(selections)
871
+ if len(requested) != len(resolved_paths):
872
+ raise ValueError(
873
+ f"merge_simulation_files got {len(requested)} batch selection(s) "
874
+ f"for {len(resolved_paths)} file(s); they must be parallel."
875
+ )
876
+ if len(resolved_paths) < 2 and all(s is None for s in requested):
877
+ raise ValueError(
878
+ "merge_simulation_files requires at least 2 files (or one file with "
879
+ "a batch selection, to keep only some of its batches)"
880
+ )
881
+
681
882
  labels = [p.name for p in resolved_paths]
682
883
  docs = [_load_json(p) for p in resolved_paths]
683
884
  for doc, label in zip(docs, labels):
@@ -685,20 +886,41 @@ def merge_simulation_files(paths: Sequence[str | Path]) -> dict:
685
886
  models = [doc["model"] for doc in docs]
686
887
  states_list = [doc["states"] for doc in docs]
687
888
  batch_sizes = [int(m.get("simBatches", 1)) for m in models]
889
+ # Resolved here rather than at the call site because a selector may name a
890
+ # batch by name or by a negative index, both of which need the file loaded.
891
+ batch_selections: list[list[int] | None] = [
892
+ None
893
+ if selector is None
894
+ else parse_batch_selection(selector, batch_size, model.get("batchNames"), label)
895
+ for selector, batch_size, model, label in zip(
896
+ requested, batch_sizes, models, labels
897
+ )
898
+ ]
688
899
 
689
900
  bodies = _merge_bodies(models, labels)
690
901
  scalar_names = _merge_scalar_names(models, labels)
691
- static_objects = _merge_static_objects(models, batch_sizes, labels)
692
- terrain = _merge_terrain(models, batch_sizes, labels)
902
+ static_objects = _merge_static_objects(
903
+ models, batch_sizes, labels, batch_selections
904
+ )
905
+ terrain = _merge_terrain(models, batch_sizes, labels, batch_selections)
693
906
  merged_states = _merge_states(
694
- states_list, batch_sizes, bodies, scalar_names, labels
907
+ states_list, batch_sizes, bodies, scalar_names, labels, batch_selections
695
908
  )
696
909
 
697
- total_batches = sum(batch_sizes)
698
- offsets = [sum(batch_sizes[:i]) for i in range(len(batch_sizes))]
910
+ out_sizes = _output_sizes(batch_sizes, batch_selections)
911
+ total_batches = sum(out_sizes)
912
+ offsets = [sum(out_sizes[:i]) for i in range(len(out_sizes))]
699
913
  ranges = ", ".join(
700
- f"'{label}' -> batches {offset}-{offset + size - 1}"
701
- for label, offset, size in zip(labels, offsets, batch_sizes)
914
+ f"'{label}'"
915
+ + (
916
+ ""
917
+ if selection is None
918
+ else f" (source batches {', '.join(str(i) for i in selection)})"
919
+ )
920
+ + f" -> batches {offset}-{offset + size - 1}"
921
+ for label, offset, size, selection in zip(
922
+ labels, offsets, out_sizes, batch_selections
923
+ )
702
924
  )
703
925
  logger.info(
704
926
  "Merged %d files into %d batches (%s)", len(paths), total_batches, ranges
@@ -706,7 +928,9 @@ def merge_simulation_files(paths: Sequence[str | Path]) -> dict:
706
928
 
707
929
  merged_model = {
708
930
  "simBatches": total_batches,
709
- "batchNames": _default_batch_names(resolved_paths, batch_sizes),
931
+ "batchNames": _default_batch_names(
932
+ resolved_paths, batch_sizes, batch_selections
933
+ ),
710
934
  "scalarNames": scalar_names,
711
935
  "dt": models[0].get("dt"),
712
936
  "collapse": models[0].get("collapse", False),