rosotacom-dev 2.2__py3-none-any.whl

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 (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,395 @@
1
+ """Ground truth from a recorded rosbag2, for replay (rosbag-driven) OTA tests.
2
+
3
+ A live test only knows what it observes; a *replay* test additionally knows its
4
+ source exactly -- the bag records, per topic, how many messages exist, over what
5
+ duration, at what QoS. That known-future lets a replay test make assertions a
6
+ live test cannot (see docs/rfcs/0002 "Live vs replay"): here we read the bag's
7
+ own ``metadata.yaml`` (pure YAML -- no rosbag2/ROS, no mcap decode) to extract
8
+ per-topic ground truth, and validate a session's declared ``expect`` against it.
9
+
10
+ The most common authoring bug this catches: an ``hz.min`` floor above the topic's
11
+ native publish rate -- the OTA link can only ever thin a stream, never speed it
12
+ up, so such a floor is unsatisfiable no matter how healthy the link.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, cast
21
+
22
+ import yaml
23
+
24
+ DEFAULT_EXPECT_MIN_RATIO = 0.9
25
+ DEFAULT_STREAM_MIN_HZ = 0.1
26
+
27
+
28
+ def _native_hz(count: int, duration_s: float) -> float | None:
29
+ if duration_s <= 0 or count <= 0:
30
+ return None
31
+ # N messages span N-1 intervals over the duration; for large N this ~= count/duration.
32
+ return (count - 1) / duration_s if count > 1 else None
33
+
34
+
35
+ def _first_qos(offered: Any) -> dict[str, Any]:
36
+ """rosbag2 stores offered_qos_profiles as a YAML string or an already-parsed
37
+ list of per-publisher profiles. Return the first profile as a dict."""
38
+ if isinstance(offered, str):
39
+ try:
40
+ offered = yaml.safe_load(offered)
41
+ except yaml.YAMLError:
42
+ return {}
43
+ if isinstance(offered, list) and offered and isinstance(offered[0], dict):
44
+ return offered[0]
45
+ if isinstance(offered, dict):
46
+ return offered
47
+ return {}
48
+
49
+
50
+ def bag_ground_truth(metadata_path: str | Path) -> dict[str, dict[str, Any]]:
51
+ """Parse a rosbag2 ``metadata.yaml`` into ``{topic: ground-truth}``.
52
+
53
+ Accepts either the metadata.yaml path or its containing bag directory. Each
54
+ value has: ``count``, ``duration_s``, ``native_hz`` (None if not derivable),
55
+ ``msg_type``, ``durability``, ``reliability``.
56
+ """
57
+ path = Path(metadata_path)
58
+ if path.is_dir():
59
+ path = path / "metadata.yaml"
60
+ doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
61
+ info = doc.get("rosbag2_bagfile_information") or {}
62
+ duration_s = float((info.get("duration") or {}).get("nanoseconds", 0)) / 1e9
63
+
64
+ out: dict[str, dict[str, Any]] = {}
65
+ for entry in info.get("topics_with_message_count") or []:
66
+ meta = entry.get("topic_metadata") or {}
67
+ name = meta.get("name")
68
+ if not name:
69
+ continue
70
+ count = int(entry.get("message_count") or 0)
71
+ qos = _first_qos(meta.get("offered_qos_profiles"))
72
+ out[str(name)] = {
73
+ "count": count,
74
+ "duration_s": round(duration_s, 3),
75
+ "native_hz": _native_hz(count, duration_s),
76
+ "msg_type": meta.get("type"),
77
+ "durability": qos.get("durability"),
78
+ "reliability": qos.get("reliability"),
79
+ }
80
+ return out
81
+
82
+
83
+ def validate_expect_against_bag(
84
+ expect_by_topic: dict[str, dict[str, Any]], ground_truth: dict[str, dict[str, Any]]
85
+ ) -> list[str]:
86
+ """Warnings where a session's per-topic ``expect`` contradicts the bag.
87
+
88
+ Pure and order-stable. Only emits a warning when the contradiction is certain
89
+ from the ground truth alone (the OTA link can thin but never amplify a rate)."""
90
+ warnings: list[str] = []
91
+ for topic, expect in expect_by_topic.items():
92
+ gt = ground_truth.get(topic)
93
+ if not gt or not isinstance(expect, dict):
94
+ continue
95
+ native_hz = gt.get("native_hz")
96
+ hz = expect.get("hz") or {}
97
+ hz_min = hz.get("min") if isinstance(hz, dict) else None
98
+ if hz_min is not None and native_hz is not None and float(hz_min) > native_hz:
99
+ warnings.append(
100
+ f"{topic}: expect.hz.min {hz_min} exceeds the bag's native rate "
101
+ f"{native_hz:.1f} Hz ({gt['count']} msgs / {gt['duration_s']}s) -- the OTA "
102
+ f"link can only thin a stream, so this floor is unsatisfiable."
103
+ )
104
+ # A bag topic published essentially once and held (transient_local) is a
105
+ # static/latched topic; a stream contract (hz floor) misdescribes it.
106
+ if (
107
+ gt.get("durability") == "transient_local"
108
+ and (gt.get("count") or 0) <= 3
109
+ and hz_min is not None
110
+ and str(expect.get("mode", "stream")).strip().lower() == "stream"
111
+ ):
112
+ warnings.append(
113
+ f"{topic}: bag publishes it {gt['count']}x as transient_local (static/held), "
114
+ f"but expect is a stream with an hz floor -- consider `mode: latched`."
115
+ )
116
+ return warnings
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class GeneratedTopicExpect:
121
+ direction: str
122
+ topic: str
123
+ expect: dict[str, Any]
124
+ comment: str
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class WholeBagExpectFragment:
129
+ topics: dict[str, tuple[GeneratedTopicExpect, ...]]
130
+ missing_session_topics: tuple[str, ...]
131
+ uncarried_bag_topics: tuple[str, ...]
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class _Shaping:
136
+ expected_count: float
137
+ expected_hz: float | None
138
+ vs_bag_factor: float | None
139
+ comments: tuple[str, ...]
140
+
141
+
142
+ def _topic_name(item: Any) -> str | None:
143
+ if isinstance(item, str):
144
+ return item
145
+ if isinstance(item, dict) and isinstance(item.get("topic"), str):
146
+ return cast(str, item["topic"])
147
+ return None
148
+
149
+
150
+ def _processing(item: Any) -> dict[str, Any]:
151
+ if isinstance(item, dict) and isinstance(item.get("processing"), dict):
152
+ return cast(dict[str, Any], item["processing"])
153
+ return {}
154
+
155
+
156
+ def _qos_is_transient_local(value: Any) -> bool:
157
+ if not isinstance(value, dict):
158
+ return False
159
+ durability = value.get("durability")
160
+ if isinstance(durability, str) and durability.strip().lower() == "transient_local":
161
+ return True
162
+ for nested in value.values():
163
+ if _qos_is_transient_local(nested):
164
+ return True
165
+ return False
166
+
167
+
168
+ def _is_latched(item: Any, gt: dict[str, Any]) -> tuple[bool, str | None]:
169
+ proc = _processing(item)
170
+ if bool(proc.get("latch")):
171
+ return True, "session processing.latch=true"
172
+ if gt.get("durability") == "transient_local":
173
+ return True, "bag QoS durability=transient_local"
174
+ if isinstance(item, dict) and _qos_is_transient_local(item.get("qos")):
175
+ return True, "session qos durability=transient_local"
176
+ return False, None
177
+
178
+
179
+ def _float_or_none(value: Any) -> float | None:
180
+ if value is None:
181
+ return None
182
+ try:
183
+ out = float(value)
184
+ except (TypeError, ValueError):
185
+ return None
186
+ return out if math.isfinite(out) else None
187
+
188
+
189
+ def _round_metric(value: float, digits: int = 3) -> float:
190
+ rounded = round(float(value), digits)
191
+ if rounded == 0 and value > 0:
192
+ return float(f"{value:.3g}")
193
+ return rounded
194
+
195
+
196
+ def _shape_expected_delivery(item: Any, gt: dict[str, Any]) -> _Shaping:
197
+ count = float(int(gt.get("count") or 0))
198
+ duration_s = _float_or_none(gt.get("duration_s")) or 0.0
199
+ native_hz = _float_or_none(gt.get("native_hz"))
200
+ expected_count = count
201
+ expected_hz = native_hz
202
+ comments: list[str] = []
203
+
204
+ proc = _processing(item)
205
+ drop = proc.get("drop")
206
+ if isinstance(drop, dict) and drop.get("drop_count") is not None and drop.get("window_size") is not None:
207
+ drop_count = int(drop.get("drop_count") or 0)
208
+ window_size = int(drop.get("window_size") or 0)
209
+ if window_size > 0 and 0 <= drop_count < window_size:
210
+ keep_ratio = (window_size - drop_count) / window_size
211
+ expected_count *= keep_ratio
212
+ if expected_hz is not None:
213
+ expected_hz *= keep_ratio
214
+ comments.append(f"drop {drop_count}/{window_size} keeps x{keep_ratio:.3g}")
215
+
216
+ throttle = proc.get("throttle_hz")
217
+ if throttle is not None:
218
+ throttle_hz = float(throttle)
219
+ if throttle_hz > 0:
220
+ if expected_hz is None:
221
+ if duration_s > 0:
222
+ expected_count = min(expected_count, throttle_hz * duration_s + 1)
223
+ comments.append(f"throttle_hz {throttle_hz:g}")
224
+ elif expected_hz > throttle_hz:
225
+ before = expected_hz
226
+ expected_hz = throttle_hz
227
+ if duration_s > 0:
228
+ expected_count = min(expected_count, throttle_hz * duration_s + 1)
229
+ comments.append(f"throttle_hz {throttle_hz:g} caps {before:.3g} Hz")
230
+ else:
231
+ comments.append(f"throttle_hz {throttle_hz:g} is not limiting")
232
+
233
+ vs_bag_factor = expected_hz / native_hz if native_hz and native_hz > 0 and expected_hz is not None else None
234
+ return _Shaping(
235
+ expected_count=expected_count,
236
+ expected_hz=expected_hz,
237
+ vs_bag_factor=vs_bag_factor,
238
+ comments=tuple(comments),
239
+ )
240
+
241
+
242
+ def _mode_for_topic(
243
+ item: Any,
244
+ gt: dict[str, Any],
245
+ *,
246
+ stream_min_hz: float,
247
+ ) -> tuple[str, str]:
248
+ latched, reason = _is_latched(item, gt)
249
+ if latched:
250
+ return "latched", str(reason)
251
+
252
+ count = int(gt.get("count") or 0)
253
+ native_hz = _float_or_none(gt.get("native_hz"))
254
+ if count <= 1:
255
+ return "existence", "bag has at most one message and is not transient_local"
256
+ if native_hz is None or native_hz < stream_min_hz:
257
+ hz_text = "unknown" if native_hz is None else f"{native_hz:.3g} Hz"
258
+ return "existence", f"native rate {hz_text} is below stream_min_hz {stream_min_hz:g}"
259
+ return "stream", f"native rate {native_hz:.3g} Hz is stream-like"
260
+
261
+
262
+ def _base_derivation(topic: str, gt: dict[str, Any]) -> str:
263
+ native_hz = _float_or_none(gt.get("native_hz"))
264
+ hz_text = "unknown Hz" if native_hz is None else f"{native_hz:.3g} Hz"
265
+ return (
266
+ f"{topic}: bag_count={int(gt.get('count') or 0)}, "
267
+ f"duration={float(gt.get('duration_s') or 0):g}s, native={hz_text}"
268
+ )
269
+
270
+
271
+ def generate_whole_bag_expectations(
272
+ cfg: dict[str, Any],
273
+ ground_truth: dict[str, dict[str, Any]],
274
+ *,
275
+ min_ratio: float = DEFAULT_EXPECT_MIN_RATIO,
276
+ stream_min_hz: float = DEFAULT_STREAM_MIN_HZ,
277
+ ) -> WholeBagExpectFragment:
278
+ """Generate mergeable per-topic ``expect`` blocks from bag metadata.
279
+
280
+ The result covers session-carried topics that are present in the bag ground
281
+ truth. Stream thresholds are scaled for intended send-side shaping
282
+ (``drop`` and ``throttle_hz``), so configured decimation is not counted as
283
+ OTA loss.
284
+ """
285
+ if not 0 < min_ratio <= 1:
286
+ raise ValueError("min_ratio must be > 0 and <= 1")
287
+ if stream_min_hz < 0:
288
+ raise ValueError("stream_min_hz must be >= 0")
289
+
290
+ topics = cfg.get("topics")
291
+ if not isinstance(topics, dict):
292
+ return WholeBagExpectFragment(
293
+ topics={},
294
+ missing_session_topics=(),
295
+ uncarried_bag_topics=tuple(sorted(ground_truth)),
296
+ )
297
+
298
+ generated: dict[str, list[GeneratedTopicExpect]] = {}
299
+ missing: list[str] = []
300
+ carried_topics: set[str] = set()
301
+ for direction, entries in topics.items():
302
+ if not isinstance(entries, list):
303
+ continue
304
+ direction_key = str(direction)
305
+ for item in entries:
306
+ topic = _topic_name(item)
307
+ if topic is None:
308
+ continue
309
+ carried_topics.add(topic)
310
+ gt = ground_truth.get(topic)
311
+ if gt is None:
312
+ missing.append(f"{direction_key}:{topic}")
313
+ continue
314
+
315
+ mode, mode_reason = _mode_for_topic(item, gt, stream_min_hz=stream_min_hz)
316
+ expect: dict[str, Any] = {"presence": "required", "mode": mode}
317
+ derivation = [_base_derivation(topic, gt), f"mode={mode} ({mode_reason})"]
318
+ if mode == "stream":
319
+ shaping = _shape_expected_delivery(item, gt)
320
+ min_count = max(1, int(math.floor(shaping.expected_count * min_ratio)))
321
+ completeness: dict[str, Any] = {"min_ratio": _round_metric(min_ratio)}
322
+ if shaping.vs_bag_factor is not None:
323
+ completeness["vs_bag_ratio"] = _round_metric(shaping.vs_bag_factor * min_ratio)
324
+ expect["min_count"] = min_count
325
+ expect["completeness"] = completeness
326
+ shape_text = "; ".join(shaping.comments) if shaping.comments else "none"
327
+ expected_hz = "unknown" if shaping.expected_hz is None else f"{shaping.expected_hz:.3g} Hz"
328
+ derivation.append(
329
+ f"shaping={shape_text}; expected={shaping.expected_count:.3g} msgs/{expected_hz}; "
330
+ f"min_ratio={min_ratio:g} -> min_count={min_count}"
331
+ )
332
+ elif mode == "latched":
333
+ derivation.append("latched mode checks held-value delivery, not rate or full count")
334
+ else:
335
+ derivation.append("existence mode checks graph presence for sparse/irregular non-latched topics")
336
+
337
+ generated.setdefault(direction_key, []).append(
338
+ GeneratedTopicExpect(
339
+ direction=direction_key,
340
+ topic=topic,
341
+ expect=expect,
342
+ comment="; ".join(derivation),
343
+ )
344
+ )
345
+
346
+ uncarried = tuple(sorted(set(ground_truth) - carried_topics))
347
+ return WholeBagExpectFragment(
348
+ topics={direction: tuple(items) for direction, items in generated.items()},
349
+ missing_session_topics=tuple(sorted(missing)),
350
+ uncarried_bag_topics=uncarried,
351
+ )
352
+
353
+
354
+ def _yaml_scalar(value: str) -> str:
355
+ dumped = str(yaml.safe_dump(value, default_flow_style=True, sort_keys=False)).strip()
356
+ if dumped.endswith("\n..."):
357
+ dumped = dumped[:-4].strip()
358
+ return dumped
359
+
360
+
361
+ def _indent(text: str, spaces: int) -> list[str]:
362
+ pad = " " * spaces
363
+ return [pad + line if line else line for line in text.splitlines()]
364
+
365
+
366
+ def render_whole_bag_expect_fragment(
367
+ fragment: WholeBagExpectFragment,
368
+ *,
369
+ bag: str | Path | None = None,
370
+ session: str | Path | None = None,
371
+ ) -> str:
372
+ """Render a parseable YAML fragment with derivation comments."""
373
+ lines = ["# Generated by `rosotacom expect from-bag`."]
374
+ if bag is not None:
375
+ lines.append(f"# Bag: {bag}")
376
+ if session is not None:
377
+ lines.append(f"# Session: {session}")
378
+ if fragment.missing_session_topics:
379
+ lines.append("# Session topics not found in the bag and skipped: " + ", ".join(fragment.missing_session_topics))
380
+ if fragment.uncarried_bag_topics:
381
+ lines.append("# Bag topics not carried by this session: " + ", ".join(fragment.uncarried_bag_topics))
382
+ lines.append("topics:")
383
+ for direction, entries in fragment.topics.items():
384
+ lines.append(f" {direction}:")
385
+ for entry in entries:
386
+ lines.append(f" - topic: {_yaml_scalar(entry.topic)}")
387
+ lines.append(f" # {entry.comment}")
388
+ dumped = yaml.safe_dump(
389
+ {"expect": entry.expect},
390
+ sort_keys=False,
391
+ default_flow_style=False,
392
+ allow_unicode=True,
393
+ ).rstrip()
394
+ lines.extend(_indent(dumped, 6))
395
+ return "\n".join(lines)