simview 6.1.2__tar.gz → 6.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 (108) hide show
  1. {simview-6.1.2/simview.egg-info → simview-6.2.0}/PKG-INFO +1 -1
  2. {simview-6.1.2 → simview-6.2.0}/pyproject.toml +1 -1
  3. {simview-6.1.2 → simview-6.2.0}/simview/__main__.py +39 -7
  4. {simview-6.1.2 → simview-6.2.0}/simview/columnar.py +46 -19
  5. {simview-6.1.2 → simview-6.2.0}/simview/diff.py +59 -23
  6. {simview-6.1.2 → simview-6.2.0}/simview/info.py +41 -1
  7. {simview-6.1.2 → simview-6.2.0}/simview/live.py +19 -8
  8. {simview-6.1.2 → simview-6.2.0}/simview/merge.py +83 -19
  9. {simview-6.1.2 → simview-6.2.0}/simview/model.py +230 -36
  10. {simview-6.1.2 → simview-6.2.0}/simview/remote.py +7 -2
  11. {simview-6.1.2 → simview-6.2.0}/simview/scene.py +174 -35
  12. {simview-6.1.2 → simview-6.2.0}/simview/server.py +84 -24
  13. {simview-6.1.2 → simview-6.2.0}/simview/state.py +22 -0
  14. {simview-6.1.2 → simview-6.2.0}/simview/static/css/controls.css +12 -0
  15. {simview-6.1.2 → simview-6.2.0}/simview/static/js/SimView.js +74 -26
  16. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/AnimationController.js +72 -16
  17. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/BatchManager.js +16 -4
  18. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/InteractionController.js +22 -4
  19. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/Scene.js +15 -1
  20. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/StateStore.js +57 -0
  21. {simview-6.1.2 → simview-6.2.0}/simview/static/js/components/WindowedField.js +16 -2
  22. {simview-6.1.2 → simview-6.2.0}/simview/static/js/config.js +2 -0
  23. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/Body.js +42 -16
  24. simview-6.2.0/simview/static/js/objects/Polyline.js +110 -0
  25. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/StaticObject.js +24 -1
  26. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/Terrain.js +55 -21
  27. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/colormap.js +12 -1
  28. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/BatchLegend.js +17 -0
  29. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/BodyStateWindow.js +4 -1
  30. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/Controls.js +40 -8
  31. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/ErrorMetrics.js +42 -25
  32. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/Legend.js +12 -4
  33. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/PlaybackControls.js +25 -12
  34. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/ScalarPlotter.js +54 -16
  35. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/TerrainProfile.js +10 -13
  36. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/chartControls.js +1 -1
  37. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/blobCodec.js +7 -0
  38. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/episodes.js +33 -9
  39. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/errorMath.js +21 -13
  40. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/interpolate.js +4 -4
  41. simview-6.2.0/simview/static/js/utils/scalarSeries.js +26 -0
  42. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/terrainSample.js +6 -11
  43. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/viewState.js +13 -4
  44. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/viewerDefaults.js +2 -0
  45. simview-6.2.0/simview/static/js/utils/visibleRanges.js +7 -0
  46. {simview-6.1.2 → simview-6.2.0}/simview/terrain.py +24 -15
  47. {simview-6.1.2 → simview-6.2.0}/simview/utils.py +21 -3
  48. {simview-6.1.2 → simview-6.2.0}/simview/view.py +35 -11
  49. {simview-6.1.2 → simview-6.2.0/simview.egg-info}/PKG-INFO +1 -1
  50. {simview-6.1.2 → simview-6.2.0}/simview.egg-info/SOURCES.txt +3 -0
  51. {simview-6.1.2 → simview-6.2.0}/tests/test_cli.py +52 -0
  52. {simview-6.1.2 → simview-6.2.0}/tests/test_columnar_ondisk.py +43 -0
  53. {simview-6.1.2 → simview-6.2.0}/tests/test_columnar_states.py +60 -0
  54. {simview-6.1.2 → simview-6.2.0}/tests/test_contacts.py +9 -0
  55. {simview-6.1.2 → simview-6.2.0}/tests/test_diff.py +44 -0
  56. {simview-6.1.2 → simview-6.2.0}/tests/test_episodes.py +7 -0
  57. {simview-6.1.2 → simview-6.2.0}/tests/test_info.py +39 -0
  58. {simview-6.1.2 → simview-6.2.0}/tests/test_live.py +89 -12
  59. {simview-6.1.2 → simview-6.2.0}/tests/test_merge.py +119 -0
  60. {simview-6.1.2 → simview-6.2.0}/tests/test_remote.py +12 -1
  61. {simview-6.1.2 → simview-6.2.0}/tests/test_roundtrip.py +59 -0
  62. {simview-6.1.2 → simview-6.2.0}/tests/test_scene.py +195 -1
  63. {simview-6.1.2 → simview-6.2.0}/tests/test_server.py +103 -0
  64. {simview-6.1.2 → simview-6.2.0}/tests/test_show.py +13 -0
  65. {simview-6.1.2 → simview-6.2.0}/tests/test_terrain_query.py +42 -0
  66. {simview-6.1.2 → simview-6.2.0}/tests/test_utils.py +21 -0
  67. {simview-6.1.2 → simview-6.2.0}/tests/test_view.py +35 -0
  68. {simview-6.1.2 → simview-6.2.0}/LICENSE +0 -0
  69. {simview-6.1.2 → simview-6.2.0}/MANIFEST.in +0 -0
  70. {simview-6.1.2 → simview-6.2.0}/README.md +0 -0
  71. {simview-6.1.2 → simview-6.2.0}/setup.cfg +0 -0
  72. {simview-6.1.2 → simview-6.2.0}/simview/__init__.py +0 -0
  73. {simview-6.1.2 → simview-6.2.0}/simview/launcher.py +0 -0
  74. {simview-6.1.2 → simview-6.2.0}/simview/py.typed +0 -0
  75. {simview-6.1.2 → simview-6.2.0}/simview/render.py +0 -0
  76. {simview-6.1.2 → simview-6.2.0}/simview/static/js/main.js +0 -0
  77. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/similarity.js +0 -0
  78. {simview-6.1.2 → simview-6.2.0}/simview/static/js/objects/utils.js +0 -0
  79. {simview-6.1.2 → simview-6.2.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
  80. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/batchPresets.js +0 -0
  81. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/batchVisibility.js +0 -0
  82. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/blobWindow.js +0 -0
  83. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/bodyTransforms.js +0 -0
  84. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/cameraRange.js +0 -0
  85. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/csv.js +0 -0
  86. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/keyboard.js +0 -0
  87. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/liveFollow.js +0 -0
  88. {simview-6.1.2 → simview-6.2.0}/simview/static/js/utils/uplot.js +0 -0
  89. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/js-colormaps.js +0 -0
  90. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  91. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  92. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  93. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  94. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/uPlot.esm.js +0 -0
  95. {simview-6.1.2 → simview-6.2.0}/simview/static/lib/uPlot.min.css +0 -0
  96. {simview-6.1.2 → simview-6.2.0}/simview/static/textures/contacts/red-cross0.png +0 -0
  97. {simview-6.1.2 → simview-6.2.0}/simview/static/textures/points/ball1.png +0 -0
  98. {simview-6.1.2 → simview-6.2.0}/simview/templates/index.html +0 -0
  99. {simview-6.1.2 → simview-6.2.0}/simview.egg-info/dependency_links.txt +0 -0
  100. {simview-6.1.2 → simview-6.2.0}/simview.egg-info/entry_points.txt +0 -0
  101. {simview-6.1.2 → simview-6.2.0}/simview.egg-info/requires.txt +0 -0
  102. {simview-6.1.2 → simview-6.2.0}/simview.egg-info/top_level.txt +0 -0
  103. {simview-6.1.2 → simview-6.2.0}/tests/test_batch_selection.py +0 -0
  104. {simview-6.1.2 → simview-6.2.0}/tests/test_embed_defaults.py +0 -0
  105. {simview-6.1.2 → simview-6.2.0}/tests/test_launcher.py +0 -0
  106. {simview-6.1.2 → simview-6.2.0}/tests/test_pointcloud_features.py +0 -0
  107. {simview-6.1.2 → simview-6.2.0}/tests/test_terrain.py +0 -0
  108. {simview-6.1.2 → simview-6.2.0}/tests/test_trajectory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 6.1.2
3
+ Version: 6.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simview"
7
- version = "6.1.2"
7
+ version = "6.2.0"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -14,9 +14,9 @@ logger = logging.getLogger("simview.cli")
14
14
 
15
15
  _logging_configured = False
16
16
 
17
- # Subcommands with their own scoped flags. "view" (bare `simview <file>...`)
18
- # has no keyword of its own -- see main()'s routing.
19
- _SUBCOMMANDS = ("info", "diff", "terrain", "render", "clear")
17
+ # Subcommands with their own scoped flags. "view" also works bare
18
+ # (`simview <file>...`) -- see main()'s routing.
19
+ _SUBCOMMANDS = ("view", "info", "diff", "terrain", "render", "clear", "static-viewer")
20
20
 
21
21
 
22
22
  def _configure_logging() -> None:
@@ -71,6 +71,20 @@ def clear_cache():
71
71
  logger.info("Cache cleared.")
72
72
 
73
73
 
74
+ def export_static_viewer(directory: Path) -> None:
75
+ """Copy the viewer page (`index.html` + `static/`) into `directory`, so a
76
+ site can host it next to `SimulationScene.save_static` bundles and open
77
+ `index.html#data=<bundle url>` with no SimView server. The page's asset
78
+ URLs are page-relative already, so the files are copied verbatim."""
79
+ from importlib.resources import files
80
+
81
+ package = files("simview")
82
+ directory.mkdir(parents=True, exist_ok=True)
83
+ shutil.copy(str(package / "templates" / "index.html"), directory / "index.html")
84
+ shutil.copytree(str(package / "static"), directory / "static", dirs_exist_ok=True)
85
+ logger.info("Static viewer written to %s", directory)
86
+
87
+
74
88
  def run_info(path: Path, as_json: bool) -> None:
75
89
  """Print a structural summary of the scene JSON at `path` to stdout."""
76
90
  from simview.info import format_text, summarize_scene
@@ -287,7 +301,13 @@ def save_merged(
287
301
  payload = json.dumps(merged).encode("utf-8")
288
302
  if out_path.suffix == ".gz":
289
303
  payload = gzip.compress(payload, compresslevel=1)
290
- out_path.write_bytes(payload)
304
+ try:
305
+ # Like SimulationScene.save: create missing parent directories.
306
+ out_path.parent.mkdir(parents=True, exist_ok=True)
307
+ out_path.write_bytes(payload)
308
+ except OSError as e:
309
+ logger.error("Error: cannot write merged scene to '%s': %s", out_path, e)
310
+ sys.exit(1)
291
311
  logger.info("Merged scene written to %s", out_path)
292
312
 
293
313
 
@@ -618,6 +638,15 @@ def build_parser() -> argparse.ArgumentParser:
618
638
 
619
639
  sub.add_parser("clear", help="Remove simview's on-disk cache.")
620
640
 
641
+ static_viewer = sub.add_parser(
642
+ "static-viewer",
643
+ help=(
644
+ "Copy the viewer page (index.html + static/) to DIR, to host it "
645
+ "as plain files next to save_static() bundles (open with #data=)."
646
+ ),
647
+ )
648
+ static_viewer.add_argument("directory", type=Path, metavar="DIR")
649
+
621
650
  return parser
622
651
 
623
652
 
@@ -638,9 +667,9 @@ def main():
638
667
  parser.print_help()
639
668
  sys.exit(1)
640
669
 
641
- # No subcommand keyword names the bare `simview <file>...` view form, so
642
- # anything that isn't a known subcommand (or -h/--help, which the top-level
643
- # parser should still handle itself) is routed to the hidden "view" one.
670
+ # The bare `simview <file>...` view form has no keyword, so anything that
671
+ # isn't a known subcommand (or -h/--help, which the top-level parser
672
+ # should still handle itself) is routed to "view".
644
673
  if argv[0] not in _SUBCOMMANDS and argv[0] not in ("-h", "--help"):
645
674
  argv = ["view", *argv]
646
675
 
@@ -664,6 +693,9 @@ def main():
664
693
  if args.command == "clear":
665
694
  clear_cache()
666
695
  return
696
+ if args.command == "static-viewer":
697
+ export_static_viewer(args.directory)
698
+ return
667
699
 
668
700
  single_file_commands = {
669
701
  "info": lambda path, args: run_info(path, as_json=args.json),
@@ -93,6 +93,10 @@ def blob_floats(value: Any) -> list[float]:
93
93
  if not value.startswith(BLOB_PREFIX):
94
94
  raise ValueError(f"expected a {BLOB_PREFIX} blob, got {value[:32]!r}")
95
95
  raw = blob_bytes(value)
96
+ if len(raw) % 4:
97
+ raise ValueError(
98
+ f"blob has {len(raw)} bytes, not a multiple of 4 (float32)"
99
+ )
96
100
  return list(struct.unpack(f"<{len(raw) // 4}f", raw))
97
101
 
98
102
  flat: list[float] = []
@@ -154,7 +158,12 @@ def _decode_state_field_rows(value, width: int, batch_size: int):
154
158
  raise StatesShapeMismatch(f"unexpected string value for field: {value!r}")
155
159
  flat = np.frombuffer(blob_bytes(value), dtype="<f4")
156
160
  else:
157
- arr = np.asarray(value, dtype="<f4")
161
+ try:
162
+ arr = np.asarray(value, dtype="<f4")
163
+ except (ValueError, TypeError) as e:
164
+ # Ragged / non-numeric rows: not repackable, keep the legacy layout
165
+ # (as documented) rather than crash the server at load.
166
+ raise StatesShapeMismatch(f"field value is not a numeric array: {e}")
158
167
  if arr.ndim == 1:
159
168
  if batch_size != 1:
160
169
  raise StatesShapeMismatch(
@@ -291,9 +300,12 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
291
300
 
292
301
  T = len(states_data)
293
302
 
294
- bodies_payload = []
303
+ # Stack everything first; register_blob only once nothing can still
304
+ # bail out, so a late mismatch doesn't leave orphaned blobs behind in
305
+ # the server (they're never referenced, never freed).
306
+ body_blobs: list[tuple[object, dict[str, bytes]]] = []
295
307
  for key in body_order:
296
- fields_payload = {}
308
+ raw_fields = {}
297
309
  for field, per_frame_rows in body_rows[key].items():
298
310
  if len(per_frame_rows) != T:
299
311
  raise StatesShapeMismatch(
@@ -303,17 +315,17 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
303
315
  stacked = np.ascontiguousarray(
304
316
  np.stack(per_frame_rows, axis=0), dtype="<f4"
305
317
  ) # (T, B, k)
306
- fields_payload[field] = register_blob(stacked.tobytes())
307
- entry = {"name": body_name_value[key], "fields": fields_payload}
308
- if key in any_contacts:
309
- entry["contacts"] = body_contacts[key]
310
- bodies_payload.append(entry)
318
+ raw_fields[field] = stacked.tobytes()
319
+ body_blobs.append((key, raw_fields))
311
320
 
312
- scalars_payload = {}
321
+ scalar_blobs: dict[str, bytes] = {}
313
322
  for name in model_data.get("scalarNames") or []:
314
323
  per_frame = []
315
324
  for state in states_data:
316
- row = np.asarray(state[name], dtype="<f4")
325
+ try:
326
+ row = np.asarray(state[name], dtype="<f4")
327
+ except (ValueError, TypeError) as e:
328
+ raise StatesShapeMismatch(f"scalar '{name}' is not numeric: {e}")
317
329
  if row.ndim == 0:
318
330
  row = row.reshape(1)
319
331
  if row.shape != (batch_size,):
@@ -323,14 +335,7 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
323
335
  )
324
336
  per_frame.append(row)
325
337
  stacked = np.ascontiguousarray(np.stack(per_frame, axis=0), dtype="<f4")
326
- scalars_payload[name] = register_blob(stacked.tobytes())
327
-
328
- return {
329
- "version": COLUMNAR_VERSION,
330
- "times": times,
331
- "bodies": bodies_payload,
332
- "scalars": scalars_payload,
333
- }
338
+ scalar_blobs[name] = stacked.tobytes()
334
339
  except StatesShapeMismatch as e:
335
340
  logger.warning(
336
341
  "States data is not columnar-repackable, keeping the legacy "
@@ -339,6 +344,23 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
339
344
  )
340
345
  return None
341
346
 
347
+ bodies_payload = []
348
+ for key, raw_fields in body_blobs:
349
+ entry = {
350
+ "name": body_name_value[key],
351
+ "fields": {f: register_blob(raw) for f, raw in raw_fields.items()},
352
+ }
353
+ if key in any_contacts:
354
+ entry["contacts"] = body_contacts[key]
355
+ bodies_payload.append(entry)
356
+
357
+ return {
358
+ "version": COLUMNAR_VERSION,
359
+ "times": times,
360
+ "bodies": bodies_payload,
361
+ "scalars": {n: register_blob(raw) for n, raw in scalar_blobs.items()},
362
+ }
363
+
342
364
 
343
365
  def expand_columnar_states(states_doc: Any, batch_size: int) -> list[dict]:
344
366
  """Inverse of `columnarize_states`: rebuild the legacy per-frame array.
@@ -441,6 +463,11 @@ def write_static_bundle(data: dict, directory) -> None:
441
463
  else:
442
464
  externalize(v)
443
465
 
466
+ externalize(data["model"])
467
+ # The viewer only resolves /blob/ URLs in the model and a columnar states
468
+ # object; a legacy per-frame array's inline __b64__ fields are decoded
469
+ # in place, so they must stay inline.
470
+ if is_columnar(data["states"]):
471
+ externalize(data["states"])
444
472
  for name in ("model", "states"):
445
- externalize(data[name])
446
473
  (out / f"{name}.json").write_text(json.dumps(data[name]))
@@ -162,14 +162,57 @@ def _resolve_frame(
162
162
  return resolved
163
163
 
164
164
 
165
+ def pose_resolver(model_data: dict, states_data: list):
166
+ """`(all_names, resolve)` for walking `states_data` in world space:
167
+ `all_names` is every body name/name-group in the states plus any
168
+ rigidly-attached body (constant `localTransform`, never written into the
169
+ states) resolvable from them, and `resolve(state, batch_idx)` maps each
170
+ body name to its absolute-world `[x, y, z, w, qx, qy, qz]` row for that
171
+ frame (parent chains composed; bodies without a pose that frame are left
172
+ out). Shared by `simview diff` and `simview terrain --along-body`."""
173
+ batch_size = int(model_data.get("simBatches") or 1)
174
+ all_names = collect_body_names(states_data)
175
+ meta = _build_body_meta(model_data, all_names)
176
+ topo_order = _topo_sort_bodies(meta)
177
+ seen_keys = {body_key(name) for name in all_names}
178
+ for name in topo_order:
179
+ if meta[name]["localTransform"] is not None and body_key(name) not in seen_keys:
180
+ seen_keys.add(body_key(name))
181
+ all_names.append(name)
182
+
183
+ def resolve(state: dict, batch_idx: int) -> dict[str, list[float]]:
184
+ return _resolve_frame(
185
+ meta, topo_order, bodies_by_name(state.get("bodies")), batch_size, batch_idx
186
+ )
187
+
188
+ return all_names, resolve
189
+
190
+
165
191
  def _quat_angle_deg(qa: list[float], qb: list[float]) -> float:
166
192
  """Angular distance in degrees between two [w, x, y, z] quaternions,
167
- robust to the double-cover ambiguity (q and -q are the same rotation)."""
193
+ robust to the double-cover ambiguity (q and -q are the same rotation).
194
+ NaN in either quaternion gives NaN, never a spurious 0."""
168
195
  dot = qa[0] * qb[0] + qa[1] * qb[1] + qa[2] * qb[2] + qa[3] * qb[3]
196
+ if math.isnan(dot):
197
+ return math.nan
169
198
  dot = max(-1.0, min(1.0, abs(dot)))
170
199
  return math.degrees(2 * math.acos(dot))
171
200
 
172
201
 
202
+ def _nan_aware_stats(values: list[float]) -> dict:
203
+ """`series_stats` over the finite entries of `values` (NaN for all of
204
+ mean/min/max when every entry is NaN), with `final` the raw last value
205
+ and `nan_count` how many entries were NaN."""
206
+ finite = [v for v in values if not math.isnan(v)]
207
+ stats = series_stats(finite)
208
+ if values and not finite:
209
+ stats = {"mean": math.nan, "min": math.nan, "max": math.nan, "final": None}
210
+ if values:
211
+ stats["final"] = values[-1]
212
+ stats["nan_count"] = len(values) - len(finite)
213
+ return stats
214
+
215
+
173
216
  def _resolve_batches(model_data: dict, batch_a: int, batch_b: int) -> int:
174
217
  batch_size = int(model_data.get("simBatches") or 1)
175
218
  if batch_size < 2:
@@ -191,7 +234,9 @@ def _first_exceeding(
191
234
  if threshold is None:
192
235
  return None
193
236
  for frame_idx, value in zip(frame_indices, values):
194
- if value > threshold:
237
+ # A NaN error (NaN pose in either batch) counts as exceeding: it's
238
+ # never "within threshold".
239
+ if value > threshold or math.isnan(value):
195
240
  return frame_idx
196
241
  return None
197
242
 
@@ -230,28 +275,20 @@ def compute_trajectory_diff(
230
275
  per-axis toggle -- see `static/js/utils/errorMath.js`'s
231
276
  `positionAxisError`) and matching `"err_x"`/`"err_y"`/`"err_z"` entries
232
277
  in `summary` (mean/max/final of the *signed* value, so directional bias
233
- is visible). Raises `ValueError` on invalid batch indices, `every < 1`,
234
- an unmatched/ambiguous `body`, or a scene with no diffable bodies.
278
+ is visible). A NaN pose yields a NaN error for that frame: summary
279
+ mean/min/max skip NaN entries (`nan_count` says how many), and a NaN
280
+ error counts as exceeding any threshold. Raises `ValueError` on invalid
281
+ batch indices, `every < 1`, an unmatched/ambiguous `body`, or a scene
282
+ with no diffable bodies.
235
283
  """
236
- batch_size = _resolve_batches(model_data, batch_a, batch_b)
284
+ _resolve_batches(model_data, batch_a, batch_b)
237
285
  if every < 1:
238
286
  raise ValueError(f"every must be >= 1; got {every}")
239
287
 
240
- all_names = collect_body_names(states_data)
241
- seen_keys = {body_key(name) for name in all_names}
288
+ all_names, resolve = pose_resolver(model_data, states_data)
242
289
  if not all_names:
243
290
  raise ValueError("no bodies found in the scene's states to diff")
244
291
 
245
- meta = _build_body_meta(model_data, all_names)
246
- topo_order = _topo_sort_bodies(meta)
247
- # Rigidly-attached children carry a constant localTransform and so never
248
- # appear in the states at all -- they're only diffable now that poses are
249
- # resolved through the parent chain.
250
- for name in topo_order:
251
- if meta[name]["localTransform"] is not None and body_key(name) not in seen_keys:
252
- seen_keys.add(body_key(name))
253
- all_names.append(name)
254
-
255
292
  target_names = resolve_body(all_names, body)
256
293
 
257
294
  # A grouped ("A+B") state entry shares one transform across its members, so
@@ -273,11 +310,10 @@ def compute_trajectory_diff(
273
310
  for idx, state in enumerate(states_data):
274
311
  if idx % every != 0:
275
312
  continue
276
- raw_by_name = bodies_by_name(state.get("bodies"))
277
313
  # Resolved once per frame for the whole scene rather than per body:
278
314
  # a child's world pose needs its ancestors' poses anyway.
279
- rows_a = _resolve_frame(meta, topo_order, raw_by_name, batch_size, batch_a)
280
- rows_b = _resolve_frame(meta, topo_order, raw_by_name, batch_size, batch_b)
315
+ rows_a = resolve(state, batch_a)
316
+ rows_b = resolve(state, batch_b)
281
317
 
282
318
  for name in target_names:
283
319
  key = body_key(name)
@@ -302,8 +338,8 @@ def compute_trajectory_diff(
302
338
  frame_indices = out["frame_indices"]
303
339
  summary = {
304
340
  "frame_count": len(frame_indices),
305
- "position_error": series_stats(out["position_error"]),
306
- "orientation_error_deg": series_stats(out["orientation_error_deg"]),
341
+ "position_error": _nan_aware_stats(out["position_error"]),
342
+ "orientation_error_deg": _nan_aware_stats(out["orientation_error_deg"]),
307
343
  "first_frame_exceeding_pos_threshold": _first_exceeding(
308
344
  frame_indices, out["position_error"], pos_threshold
309
345
  ),
@@ -323,7 +359,7 @@ def compute_trajectory_diff(
323
359
  body_out["summary"] = summary
324
360
  for axis in axes:
325
361
  body_out[axis] = out[axis]
326
- summary[axis] = series_stats(out[axis])
362
+ summary[axis] = _nan_aware_stats(out[axis])
327
363
  bodies_out[body_label(name)] = body_out
328
364
 
329
365
  return {
@@ -38,9 +38,32 @@ def _blob_byte_length(value: Any) -> int | None:
38
38
 
39
39
 
40
40
  def _encoding_of(value: Any) -> str:
41
+ if value is None:
42
+ return "absent"
41
43
  return "blob" if is_blob(value) else "plain"
42
44
 
43
45
 
46
+ def _field_shape_reason(value: Any, width: int, batch_size: int) -> str | None:
47
+ """Why `columnarize_states` would reject this per-body field value (its
48
+ float count must be `batch_size * width`; a flat list only for a
49
+ single batch), or None if it's fine. Counts floats without decoding
50
+ plain lists into arrays."""
51
+ if is_blob(value):
52
+ count = len(blob_bytes(value)) // 4
53
+ elif isinstance(value, list):
54
+ if value and not isinstance(value[0], list):
55
+ if batch_size != 1:
56
+ return "is a flat list but the scene has more than one batch"
57
+ count = len(value)
58
+ else:
59
+ count = sum(len(row) if isinstance(row, list) else 1 for row in value)
60
+ else:
61
+ return f"is a {type(value).__name__}, not a list or blob"
62
+ if count != batch_size * width:
63
+ return f"has {count} floats; expected {batch_size * width}"
64
+ return None
65
+
66
+
44
67
  def _summarize_terrain(terrain: dict) -> dict:
45
68
  dims = terrain.get("dimensions") or {}
46
69
  bounds = terrain.get("bounds") or {}
@@ -141,6 +164,12 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
141
164
  except ValueError as e:
142
165
  warnings.append(f"columnar states document is malformed: {e}")
143
166
  states = []
167
+ elif not isinstance(states, list):
168
+ warnings.append(
169
+ "'states' is neither a per-frame array nor a columnar document "
170
+ f"(got {type(states).__name__})"
171
+ )
172
+ states = []
144
173
 
145
174
  frame_count = len(states)
146
175
  dt_from_model = model.get("dt") if model else None
@@ -221,6 +250,7 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
221
250
  body_encodings_by_field: dict = {}
222
251
  body_frames_present: dict = {}
223
252
  body_contacts_frames: dict = {}
253
+ body_shape_reported: set = set()
224
254
 
225
255
  for idx, state in enumerate(states):
226
256
  bodies = state.get("bodies") or []
@@ -253,6 +283,16 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
253
283
  for field in fields:
254
284
  encodings = body_encodings_by_field[key].setdefault(field, set())
255
285
  encodings.add(_encoding_of(body[field]))
286
+ # Same row-shape rule columnarize_states applies, checked
287
+ # cheaply (float counts only) so the verdict matches.
288
+ shape_reason = _field_shape_reason(
289
+ body[field], STATE_FIELD_WIDTHS[field], batch_size
290
+ )
291
+ if shape_reason and (key, field) not in body_shape_reported:
292
+ body_shape_reported.add((key, field))
293
+ reasons.append(
294
+ f"body '{name}' field '{field}' in frame {idx} {shape_reason}"
295
+ )
256
296
 
257
297
  if "contacts" in body:
258
298
  body_contacts_frames.setdefault(key, []).append(idx)
@@ -373,7 +413,7 @@ def summarize_scene(path: str | Path) -> dict:
373
413
  warnings.append("'states' key missing from file")
374
414
 
375
415
  size_bytes = path.stat().st_size
376
- if size_bytes > _LARGE_FILE_BYTES:
416
+ if size_bytes > _LARGE_FILE_BYTES and not gzipped:
377
417
  warnings.append(
378
418
  f"file is large ({human_bytes(size_bytes)}); consider gzip compression"
379
419
  )
@@ -55,9 +55,14 @@ class LiveViewer:
55
55
  "(e.g. terrain might be missing)."
56
56
  )
57
57
  self.scene = scene
58
+ scene.reconcile_available_attributes()
58
59
  self.server = SimViewServer(
59
60
  data={"model": scene.model.to_json(), "states": []}, live=True
60
61
  )
62
+ # Frames already in the scene are neither served nor buffered, but
63
+ # episode startIndex values index the full scene.states, so the live
64
+ # frame indices must start after them.
65
+ self.server.frames_pushed = len(scene.states)
61
66
 
62
67
  self._threaded = ViewerHandle(
63
68
  self.server.app,
@@ -101,8 +106,9 @@ class LiveViewer:
101
106
  self.scene.add_state(time, body_states, scalar_values=scalar_values)
102
107
  frame = self.scene.states[before]
103
108
 
104
- self.server.frame_buffer.append(frame)
105
- self._enqueue(frame)
109
+ index = self.server.frames_pushed
110
+ self.server.push_frame(frame)
111
+ self._enqueue((index, frame))
106
112
 
107
113
  def mark_episode(self, label: str | None = None, start_index: int | None = None):
108
114
  """Mark the start of an episode at the current point in the stream.
@@ -146,7 +152,7 @@ class LiveViewer:
146
152
  """
147
153
  return self._dropped_frames
148
154
 
149
- def _enqueue(self, frame: dict) -> None:
155
+ def _enqueue(self, frame: tuple[int, dict]) -> None:
150
156
  """Queue one frame for the sender thread, decimating under backpressure.
151
157
 
152
158
  When the queue is full (a slow or hung browser tab), the *oldest*
@@ -187,12 +193,12 @@ class LiveViewer:
187
193
  happens, instead of in the caller's simulation loop.
188
194
  """
189
195
  while True:
190
- frame = self._send_queue.get()
191
- if frame is _STOP_SENTINEL:
196
+ item = self._send_queue.get()
197
+ if item is _STOP_SENTINEL:
192
198
  return
193
- self._broadcast(frame)
199
+ self._broadcast(*item)
194
200
 
195
- def _broadcast(self, frame: dict) -> None:
201
+ def _broadcast(self, index: int, frame: dict) -> None:
196
202
  loop = self.server.loop
197
203
  if loop is None:
198
204
  # Startup blocks until uvicorn's Server.started is set, which
@@ -205,10 +211,15 @@ class LiveViewer:
205
211
  return
206
212
 
207
213
  future = asyncio.run_coroutine_threadsafe(
208
- self.server.broadcast_frame(frame), loop
214
+ self.server.broadcast_frame(frame, index), loop
209
215
  )
210
216
  try:
211
217
  future.result(timeout=_BROADCAST_TIMEOUT)
218
+ except TimeoutError:
219
+ # Cancel it, or the stalled broadcast keeps running on the loop and
220
+ # interleaves with the next frame's (out-of-order delivery).
221
+ future.cancel()
222
+ logger.warning("Timed out broadcasting live state frame; skipped.")
212
223
  except Exception:
213
224
  logger.exception("Error broadcasting live state frame")
214
225