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,475 @@
1
+ """Evaluate a session's self-reported status (status.json) against the per-topic
2
+ `expect` contract declared in its session-definition.
3
+
4
+ This is the test-side half of the expectation-driven model (see
5
+ docs/rfcs/0001-expectation-driven-test-suite.md and
6
+ docs/rfcs/0002-expectation-concepts.md): the live status overview already
7
+ classifies every topic (state/quality/hz/latency per stage); `rosotacom test`
8
+ reads that self-report and asserts each crossed topic meets its declared
9
+ contract. Pure functions (no ROS/Docker), so they are unit-testable against
10
+ status.json fixtures.
11
+
12
+ Per-topic `expect` supports (all optional):
13
+
14
+ presence: required | optional (default required) -- an optional topic that
15
+ does not deliver is not a failure (e.g. a bidirectional command
16
+ topic with no source in a one-directional replay test).
17
+ mode: stream | latched | existence (default stream)
18
+ - stream: must deliver end-to-end and currently be FLOWING; hz /
19
+ latency_ms / quality are asserted.
20
+ - latched: must have delivered its value at least once (and may be
21
+ held via transient_local). Rate is NOT required -- a
22
+ static/latched topic publishes on change and then idles.
23
+ - existence: only requires the topic to be present in the graph (a
24
+ publisher exists). For irregular topics where neither
25
+ rate nor a held value is meaningful.
26
+ hz: { min, max } (stream only)
27
+ latency_ms: { max, stage } (stream only) -- asserted on the final delivered
28
+ stage by default; `stage: <name>` redirects it to a named pipeline stage.
29
+ Use `stage: com_in` for a wrapped (use_ota_wrapper) topic so true OTA
30
+ transit latency is read from the OtaStamped send-time the relay injects,
31
+ even when the final unwrapped payload is headerless.
32
+ loss_pct: { max, stage } (stream only) -- exact sequence-gap loss for
33
+ EchoHeartbeat and wrapped OtaStamped streams. Defaults to the first
34
+ stage exposing sequence metrics (normally inbound `com_in`).
35
+ min_count: N (stream only) -- the delivered (final flowing)
36
+ stage must have observed at least N messages over the run. A floor on
37
+ volume: distinguishes a real stream that crossed end-to-end from a
38
+ single sample that trickled through. Robust to clock skew (one peer's
39
+ own cumulative count).
40
+ completeness: { min_ratio: R } (stream only) -- within THIS peer's pipeline,
41
+ final_stage_count / first_flowing_stage_count >= R. Catches a stage
42
+ that is FLOWING but dropping (a lossy relay/framebridge/transport):
43
+ the messages enter the pipeline but a fraction never reach the end.
44
+ Uses a single monitor's counts, so there is no cross-peer timing
45
+ fragility. For the true cross-peer "did every bag message arrive",
46
+ see the replay-only section of docs/rfcs/0002.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from typing import Any
52
+
53
+ STATUS_OK = "OK"
54
+
55
+ # Stage states reported by the status overview (status_overview_core.py).
56
+ _DELIVERED_STATES = {"FLOWING", "STALE"} # a message was observed at least once
57
+
58
+ _VALID_MODES = {"stream", "latched", "existence"}
59
+ _VALID_PRESENCE = {"required", "optional"}
60
+
61
+ # RFC 0004 invariant/conditional split. Invariant (correctness) holds under every
62
+ # profile and stays top-level; conditional (performance) is asserted per profile and
63
+ # may be overridden via expect.per_profile.<name>.
64
+ _INVARIANT_KEYS = frozenset({"presence", "mode"})
65
+ _CONDITIONAL_KEYS = frozenset({"hz", "latency_ms", "loss_pct", "completeness", "min_count"})
66
+
67
+
68
+ def resolve_expect_for_profile(expect: dict[str, Any], profile: str | None) -> dict[str, Any]:
69
+ """Resolve a topic's effective `expect` under a network profile (RFC 0004).
70
+
71
+ The invariant block (presence/mode — correctness) holds under every profile and
72
+ is kept verbatim; the conditional block (hz / latency_ms / loss_pct /
73
+ completeness / min_count — performance) defaults to the top-level values and is
74
+ overridden, key by key, by `expect.per_profile[profile]` where present. An
75
+ unshaped run (`profile is None`) uses the default conditional. The `per_profile`
76
+ key is stripped from the result, so the evaluator never sees it.
77
+
78
+ Raises if a per-profile block tries to override an invariant key (those stay
79
+ profile-independent) or names an unknown conditional key — an authoring bug worth
80
+ catching at the gate."""
81
+ resolved = {key: value for key, value in expect.items() if key != "per_profile"}
82
+ per_profile = expect.get("per_profile")
83
+ if per_profile is None or profile is None:
84
+ return resolved
85
+ if not isinstance(per_profile, dict):
86
+ raise ValueError("expect.per_profile must be a mapping of profile name -> conditional overrides")
87
+ overrides = per_profile.get(profile)
88
+ if overrides is None:
89
+ return resolved
90
+ if not isinstance(overrides, dict):
91
+ raise ValueError(f"expect.per_profile.{profile} must be a mapping of conditional overrides")
92
+ invalid = sorted(set(overrides) & _INVARIANT_KEYS)
93
+ if invalid:
94
+ raise ValueError(
95
+ f"expect.per_profile.{profile} may only override conditional (performance) keys; invariant "
96
+ f"keys {invalid} stay top-level and profile-independent (RFC 0004)"
97
+ )
98
+ unknown = sorted(set(overrides) - _CONDITIONAL_KEYS)
99
+ if unknown:
100
+ raise ValueError(
101
+ f"expect.per_profile.{profile} has unsupported keys {unknown}; "
102
+ f"conditional keys are {sorted(_CONDITIONAL_KEYS)}"
103
+ )
104
+ resolved.update(overrides)
105
+ return resolved
106
+
107
+
108
+ def expectations_from_cfg(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
109
+ """Map a topic's base name -> its `expect` block, from a session config."""
110
+ out: dict[str, dict[str, Any]] = {}
111
+ topics = cfg.get("topics")
112
+ if not isinstance(topics, dict):
113
+ return out
114
+ for entries in topics.values():
115
+ for item in entries or []:
116
+ if isinstance(item, dict) and isinstance(item.get("expect"), dict) and item.get("topic"):
117
+ out[str(item["topic"])] = item["expect"]
118
+ return out
119
+
120
+
121
+ def link_expect_from_cfg(cfg: dict[str, Any]) -> dict[str, Any]:
122
+ """The session-level link-overhead expectation, from a top-level `link:` block.
123
+
124
+ e.g. link: { max_ratio: 4.0 } # both directions
125
+ link: { max_ratio_out: 3, max_ratio_in: 6 }
126
+ """
127
+ link = cfg.get("link")
128
+ return link if isinstance(link, dict) else {}
129
+
130
+
131
+ def _check_link(peer: str, link: dict[str, Any] | None, expect: dict[str, Any]) -> list[str]:
132
+ """Assert the measured link/payload overhead ratio stays under its bound.
133
+
134
+ Skips silently when there is no link sample (sampling disabled / no interface)
135
+ or no payload in a direction (ratio is None): the ratio is only meaningful
136
+ when both the wire and the ROS payload are flowing."""
137
+ if not link or not expect:
138
+ return []
139
+ failures: list[str] = []
140
+ default_max = expect.get("max_ratio")
141
+ checks = [
142
+ (
143
+ "outbound",
144
+ link.get("overhead_ratio_out"),
145
+ expect.get("max_ratio_out", default_max),
146
+ link.get("link_tx_kbps"),
147
+ link.get("ros_payload_out_kbps"),
148
+ ),
149
+ (
150
+ "inbound",
151
+ link.get("overhead_ratio_in"),
152
+ expect.get("max_ratio_in", default_max),
153
+ link.get("link_rx_kbps"),
154
+ link.get("ros_payload_in_kbps"),
155
+ ),
156
+ ]
157
+ for direction, ratio, max_ratio, link_kbps, payload_kbps in checks:
158
+ if max_ratio is None or ratio is None:
159
+ continue
160
+ if ratio > float(max_ratio):
161
+ failures.append(
162
+ f"[{peer}] link overhead ({direction}) ratio {ratio} > max {max_ratio} "
163
+ f"(wire {link_kbps} kbps vs ROS payload {payload_kbps} kbps -- "
164
+ f"retransmits / shadow connections / bad QoS?)"
165
+ )
166
+ return failures
167
+
168
+
169
+ def _topic_mode(expect: dict[str, Any]) -> str:
170
+ mode = str(expect.get("mode", "stream")).strip().lower() or "stream"
171
+ if mode not in _VALID_MODES:
172
+ raise ValueError(f"expect.mode must be one of {sorted(_VALID_MODES)}, got {mode!r}")
173
+ return mode
174
+
175
+
176
+ def _is_optional(expect: dict[str, Any]) -> bool:
177
+ presence = str(expect.get("presence", "required")).strip().lower() or "required"
178
+ if presence not in _VALID_PRESENCE:
179
+ raise ValueError(f"expect.presence must be one of {sorted(_VALID_PRESENCE)}, got {presence!r}")
180
+ return presence == "optional"
181
+
182
+
183
+ def _final_stage(topic: dict[str, Any]) -> dict[str, Any] | None:
184
+ """The deepest *flowing* stage of a topic pipeline (its delivered end)."""
185
+ stages: list[dict[str, Any]] = topic.get("stages") or []
186
+ flowing = [s for s in stages if s.get("state") == "FLOWING"]
187
+ if flowing:
188
+ return flowing[-1]
189
+ return stages[-1] if stages else None
190
+
191
+
192
+ def _was_delivered(topic: dict[str, Any]) -> bool:
193
+ """True if this peer's end of the pipeline ever observed a message (the final
194
+ stage is FLOWING or STALE). For a latched topic this means the held value
195
+ reached its destination even though it no longer ticks."""
196
+ stages: list[dict[str, Any]] = topic.get("stages") or []
197
+ if not stages:
198
+ return False
199
+ return stages[-1].get("state") in _DELIVERED_STATES
200
+
201
+
202
+ def _was_produced(topic: dict[str, Any]) -> bool:
203
+ """True if any stage was ever observed flowing. For an OUTBOUND latched topic
204
+ the held value is a one-shot: the source/latch stages show it (FLOWING/STALE)
205
+ but the inferred OTA-send stage may not keep ticking, so requiring the final
206
+ stage is wrong on the sender. Actual OTA delivery is asserted by the receiver's
207
+ inbound report."""
208
+ stages: list[dict[str, Any]] = topic.get("stages") or []
209
+ return any(s.get("state") in _DELIVERED_STATES for s in stages)
210
+
211
+
212
+ def _has_publisher(topic: dict[str, Any]) -> bool:
213
+ """True if any stage advertises a publisher (the topic exists in the graph)."""
214
+ stages: list[dict[str, Any]] = topic.get("stages") or []
215
+ return any((s.get("publishers") or 0) > 0 for s in stages)
216
+
217
+
218
+ def _named_stage(topic: dict[str, Any], name: str) -> dict[str, Any] | None:
219
+ stages: list[dict[str, Any]] = topic.get("stages") or []
220
+ for s in stages:
221
+ if s.get("stage") == name:
222
+ return s
223
+ return None
224
+
225
+
226
+ def _check_expect(
227
+ peer: str, base: str, topic: dict[str, Any], stage: dict[str, Any], expect: dict[str, Any]
228
+ ) -> list[str]:
229
+ failures: list[str] = []
230
+ hz, hz_exp = stage.get("hz"), expect.get("hz") or {}
231
+ if "min" in hz_exp and (hz is None or hz < hz_exp["min"]):
232
+ failures.append(f"[{peer}] {base}: hz {hz} < expected min {hz_exp['min']}")
233
+ if "max" in hz_exp and (hz is None or hz > hz_exp["max"]):
234
+ failures.append(f"[{peer}] {base}: hz {hz} > expected max {hz_exp['max']}")
235
+ lat_exp = expect.get("latency_ms") or {}
236
+ # latency_ms.stage redirects the latency check to a named stage -- e.g. `com_in`,
237
+ # where a wrapped (OtaStamped) topic carries the relay's injected send-time, so
238
+ # TRUE OTA latency is assertable even when the final unwrapped payload is headerless.
239
+ lat_stage = stage
240
+ stage_name = lat_exp.get("stage")
241
+ if stage_name:
242
+ named = _named_stage(topic, stage_name)
243
+ if named is None:
244
+ failures.append(f"[{peer}] {base}: latency_ms.stage '{stage_name}' not in pipeline")
245
+ return failures
246
+ lat_stage = named
247
+ lat = lat_stage.get("latency_ms")
248
+ if "max" in lat_exp and (lat is None or lat > lat_exp["max"]):
249
+ where = f" at '{stage_name}'" if stage_name else ""
250
+ failures.append(f"[{peer}] {base}: latency {lat}ms{where} > expected max {lat_exp['max']}ms")
251
+
252
+ loss_exp = expect.get("loss_pct") or {}
253
+ if "max" in loss_exp:
254
+ loss_stage = None
255
+ loss_stage_name = loss_exp.get("stage")
256
+ if loss_stage_name:
257
+ loss_stage = _named_stage(topic, loss_stage_name)
258
+ if loss_stage is None:
259
+ failures.append(f"[{peer}] {base}: loss_pct.stage '{loss_stage_name}' not in pipeline")
260
+ return failures
261
+ else:
262
+ loss_stage = next(
263
+ (candidate for candidate in topic.get("stages") or [] if candidate.get("loss_pct") is not None),
264
+ None,
265
+ )
266
+ loss = loss_stage.get("loss_pct") if loss_stage is not None else None
267
+ if loss is None or loss > loss_exp["max"]:
268
+ where = f" at '{loss_stage_name}'" if loss_stage_name else ""
269
+ failures.append(f"[{peer}] {base}: loss {loss}%{where} > expected max {loss_exp['max']}%")
270
+ return failures
271
+
272
+
273
+ def _stage_count(stage: dict[str, Any]) -> int:
274
+ try:
275
+ return int(stage.get("messages_total") or 0)
276
+ except (TypeError, ValueError):
277
+ return 0
278
+
279
+
280
+ def _check_completeness(peer: str, base: str, topic: dict[str, Any], expect: dict[str, Any]) -> list[str]:
281
+ """Volume/loss assertions over a single peer's own per-stage message counts."""
282
+ failures: list[str] = []
283
+ min_count = expect.get("min_count")
284
+ comp = expect.get("completeness") or {}
285
+ min_ratio = comp.get("min_ratio")
286
+ if min_count is None and min_ratio is None:
287
+ return failures
288
+
289
+ delivered = [s for s in (topic.get("stages") or []) if s.get("state") in _DELIVERED_STATES]
290
+ if not delivered:
291
+ return failures # presence/mode already account for nothing being delivered
292
+ final_count = _stage_count(delivered[-1])
293
+
294
+ if min_count is not None and final_count < int(min_count):
295
+ failures.append(f"[{peer}] {base}: delivered {final_count} msgs < expected min_count {int(min_count)}")
296
+ if min_ratio is not None:
297
+ first = delivered[0]
298
+ first_count = _stage_count(first)
299
+ if first_count > 0:
300
+ ratio = final_count / first_count
301
+ if ratio < float(min_ratio):
302
+ failures.append(
303
+ f"[{peer}] {base}: completeness {final_count}/{first_count}={ratio:.2f} "
304
+ f"< expected min_ratio {min_ratio} (dropping between "
305
+ f"'{first.get('stage')}' and '{delivered[-1].get('stage')}')"
306
+ )
307
+ return failures
308
+
309
+
310
+ def _check_bag_completeness(
311
+ peer: str,
312
+ base: str,
313
+ stage: dict[str, Any],
314
+ expect: dict[str, Any],
315
+ ground_truth: dict[str, Any] | None,
316
+ ) -> list[str]:
317
+ """Cross-reference the delivered rate against the bag's known native rate (replay).
318
+
319
+ `expect.completeness.vs_bag_ratio: R` asserts delivered_hz >= R * native_hz, i.e.
320
+ at least R of the source actually crossed the OTA link. Unlike the within-peer
321
+ completeness ratio, this catches loss that happens BEFORE the first observed
322
+ stage (e.g. best_effort decimation at the send QoS). Only meaningful in a replay
323
+ test, so it is skipped unless a bag ground truth was supplied."""
324
+ comp = expect.get("completeness") or {}
325
+ vs_bag = comp.get("vs_bag_ratio")
326
+ if vs_bag is None or not ground_truth:
327
+ return []
328
+ gt = ground_truth.get(base) or {}
329
+ native_hz = gt.get("native_hz")
330
+ if not native_hz or native_hz <= 0:
331
+ return []
332
+ delivered_hz = stage.get("hz") or 0.0
333
+ ratio = delivered_hz / native_hz
334
+ if ratio < float(vs_bag):
335
+ return [
336
+ f"[{peer}] {base}: delivered {delivered_hz:.1f} Hz is {ratio:.0%} of the bag's "
337
+ f"native {native_hz:.1f} Hz (< vs_bag_ratio {vs_bag}) -- excessive OTA loss."
338
+ ]
339
+ return []
340
+
341
+
342
+ def evaluate_report(
343
+ report: dict[str, Any],
344
+ expect_by_topic: dict[str, dict[str, Any]],
345
+ link_expect: dict[str, Any] | None = None,
346
+ ground_truth: dict[str, Any] | None = None,
347
+ profile: str | None = None,
348
+ ) -> list[str]:
349
+ """Failures for one peer's status.json (empty == all good).
350
+
351
+ Under `--profile P` the invariant block is asserted as always and each topic's
352
+ conditional bounds come from `P`'s `per_profile` override (RFC 0004); an unshaped
353
+ run (`profile is None`) uses the default conditional."""
354
+ peer = report.get("peer", "?")
355
+ topics = report.get("topics", [])
356
+ if not topics:
357
+ return [f"[{peer}] status report has no topics"]
358
+ failures: list[str] = _check_link(peer, report.get("link"), link_expect or {})
359
+ for topic in topics:
360
+ base = topic.get("base", "?")
361
+ expect = resolve_expect_for_profile(expect_by_topic.get(base) or {}, profile)
362
+ if expect.get("smoke_probe") is False:
363
+ continue
364
+ mode = _topic_mode(expect)
365
+ optional = _is_optional(expect)
366
+ overall = topic.get("overall")
367
+
368
+ if overall == STATUS_OK:
369
+ stage = _final_stage(topic)
370
+ # Stream contracts assert delivered behaviour against the monitor's
371
+ # own verdict and the declared thresholds. latched/existence topics
372
+ # only need to have reached OK, which they have.
373
+ if mode == "stream":
374
+ if stage and stage.get("quality") == "BAD":
375
+ if topic.get("direction") == "inbound":
376
+ reason = stage.get("quality_reason")
377
+ detail = f": {reason}" if reason else ""
378
+ failures.append(f"[{peer}] {base}: contract violated (quality BAD{detail})")
379
+ continue
380
+ if expect and topic.get("direction") == "inbound" and stage is not None:
381
+ failures += _check_expect(peer, base, topic, stage, expect)
382
+ failures += _check_completeness(peer, base, topic, expect)
383
+ failures += _check_bag_completeness(peer, base, stage, expect, ground_truth)
384
+ continue
385
+
386
+ # overall != OK -- reinterpret per the declared delivery mode.
387
+ if mode == "latched":
388
+ # Receiver (inbound): the held value must have reached the final stage.
389
+ # Sender (outbound): a one-shot held value need only have been produced
390
+ # and latched -- its OTA delivery is asserted by the receiver's report.
391
+ if topic.get("direction") == "outbound":
392
+ if _was_produced(topic):
393
+ continue
394
+ elif _was_delivered(topic):
395
+ continue
396
+ if mode == "existence" and _has_publisher(topic):
397
+ continue # present in the graph as required
398
+ if optional:
399
+ continue # optional: non-delivery is not a failure
400
+
401
+ diag = topic.get("diagnosis")
402
+ failures.append(f"[{peer}] {base}: status {overall}" + (f" ({diag})" if diag else ""))
403
+ return failures
404
+
405
+
406
+ def evaluate_reports(
407
+ reports: dict[str, dict[str, Any]],
408
+ expect_by_topic: dict[str, dict[str, Any]],
409
+ link_expect: dict[str, Any] | None = None,
410
+ ground_truth: dict[str, Any] | None = None,
411
+ profile: str | None = None,
412
+ ) -> list[str]:
413
+ """Failures across every peer's status.json (empty == all good)."""
414
+ failures: list[str] = []
415
+ for report in reports.values():
416
+ failures += evaluate_report(report, expect_by_topic, link_expect, ground_truth, profile)
417
+ return failures
418
+
419
+
420
+ def _suggest_one(topic: dict[str, Any]) -> dict[str, Any] | None:
421
+ """A suggested `expect` block for one delivered inbound topic, from observation."""
422
+ stage = _final_stage(topic)
423
+ if stage is None or stage.get("state") not in _DELIVERED_STATES:
424
+ # Never delivered in the reference run -> a one-directional/no-source topic.
425
+ return {"presence": "optional"}
426
+ hz = stage.get("hz")
427
+ if not hz or hz <= 0:
428
+ # Delivered but not ticking -> a static/latched held value.
429
+ return {"mode": "latched"}
430
+ expect: dict[str, Any] = {
431
+ # A band around the observed rate: wide enough to tolerate run-to-run
432
+ # jitter, tight enough to catch a regression. Author should narrow these.
433
+ "hz": {"min": max(0.1, round(hz * 0.6, 1)), "max": round(hz * 1.5, 1)}
434
+ }
435
+ lat = stage.get("latency_ms")
436
+ if lat is not None:
437
+ expect["latency_ms"] = {"max": int(round(lat * 2 + 50))}
438
+ return expect
439
+
440
+
441
+ def suggest_expectations(reports: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
442
+ """Suggest an `expect` block per topic from a reference run's status.json(s).
443
+
444
+ Uses the inbound (receiver) view -- the side where the contract is asserted --
445
+ and the deepest delivered stage's observed hz/latency. A starting point to
446
+ author/`calibrate` a contract, not a substitute for human judgement. Pure and
447
+ order-stable; topics seen only outbound (no inbound observation) are skipped."""
448
+ out: dict[str, dict[str, Any]] = {}
449
+ for report in reports.values():
450
+ for topic in report.get("topics", []):
451
+ if topic.get("direction") != "inbound":
452
+ continue
453
+ base = topic.get("base")
454
+ if not base or base in out:
455
+ continue
456
+ suggestion = _suggest_one(topic)
457
+ if suggestion is not None:
458
+ out[str(base)] = suggestion
459
+ return out
460
+
461
+
462
+ def suggest_profile_band(reports: dict[str, dict[str, Any]], profile: str) -> dict[str, dict[str, Any]]:
463
+ """A per-topic ``per_profile[profile]`` conditional band from a reference replay
464
+ run under profile `P` (RFC 0004 per-profile calibration).
465
+
466
+ Reuses the RFC 0002 `--suggest` machinery but keeps only the conditional
467
+ (performance) keys, nested under `per_profile[profile]` — the invariant block is
468
+ authored once and is profile-independent, so it is not re-emitted here. The
469
+ result merges straight into an existing topic's `expect`. Pure and order-stable."""
470
+ out: dict[str, dict[str, Any]] = {}
471
+ for topic, expect in suggest_expectations(reports).items():
472
+ conditional = {key: value for key, value in expect.items() if key in _CONDITIONAL_KEYS}
473
+ if conditional:
474
+ out[topic] = {"per_profile": {profile: conditional}}
475
+ return out