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.
- rosotacom/__init__.py +32 -0
- rosotacom/__main__.py +8 -0
- rosotacom/anonymize.py +360 -0
- rosotacom/bag_ground_truth.py +395 -0
- rosotacom/benched_set.py +492 -0
- rosotacom/benchmark.py +1842 -0
- rosotacom/bundle_check.py +517 -0
- rosotacom/cli.py +8637 -0
- rosotacom/cli_benchmark.py +8211 -0
- rosotacom/deployment.py +196 -0
- rosotacom/ffmpeg_packet.py +123 -0
- rosotacom/forensics.py +1172 -0
- rosotacom/geomap.py +848 -0
- rosotacom/network_profiles.py +563 -0
- rosotacom/network_shaper.py +127 -0
- rosotacom/plots.py +609 -0
- rosotacom/py.typed +0 -0
- rosotacom/resources/benched-set.yaml +317 -0
- rosotacom/resources/examples/.gitignore +1 -0
- rosotacom/resources/examples/README.md +163 -0
- rosotacom/resources/examples/__init__.py +1 -0
- rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
- rosotacom/resources/examples/deployment.example.yaml +14 -0
- rosotacom/resources/examples/profiles.yaml +85 -0
- rosotacom/resources/examples/ros2docker.json +33 -0
- rosotacom/resources/examples/rosotacom.yaml +12 -0
- rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
- rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
- rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
- rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
- rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
- rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
- rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
- rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
- rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
- rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
- rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
- rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
- rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
- rosotacom/resources/ros2docker.json.example +33 -0
- rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
- rosotacom/resources/ws/.vscode/settings.json +8 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
- rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
- rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
- rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
- rosotacom/resources/ws/ota_configs/utils.py +74 -0
- rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
- rosotacom/resources/ws/ros2src/.gitkeep +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
- rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
- rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
- rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
- rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
- rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
- rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
- rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
- rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
- rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
- rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
- rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
- rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
- rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
- rosotacom/resources/ws/session/creation/run_session.py +447 -0
- rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
- rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
- rosotacom/status_eval.py +475 -0
- rosotacom/stream_stats.py +650 -0
- rosotacom/trace_profiles.py +561 -0
- rosotacom/transit.py +249 -0
- rosotacom/video_quality.py +906 -0
- rosotacom_dev-2.2.dist-info/METADATA +874 -0
- rosotacom_dev-2.2.dist-info/RECORD +195 -0
- rosotacom_dev-2.2.dist-info/WHEEL +5 -0
- rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
- rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
- 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)
|