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
rosotacom/transit.py ADDED
@@ -0,0 +1,249 @@
1
+ """Offline analysis for RFC 0003 transit records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def _percentile(values: list[float], percentile: float) -> float | None:
13
+ if not values:
14
+ return None
15
+ ordered = sorted(values)
16
+ rank = max(0, min(len(ordered) - 1, math.ceil(percentile * len(ordered)) - 1))
17
+ return round(ordered[rank], 3)
18
+
19
+
20
+ def load_transit_records(paths: Iterable[Path]) -> list[dict[str, Any]]:
21
+ records: list[dict[str, Any]] = []
22
+ for path in paths:
23
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
24
+ if not line.strip():
25
+ continue
26
+ try:
27
+ event = json.loads(line)
28
+ except json.JSONDecodeError as exc:
29
+ raise ValueError(f"{path}:{line_number}: invalid JSON: {exc}") from exc
30
+ if event.get("kind") == "transit":
31
+ records.append(event)
32
+ return records
33
+
34
+
35
+ def join_transit_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
36
+ """Join duplicate observations by ``(topic, seq)`` and keep the richest row."""
37
+ joined: dict[tuple[str, str, int], dict[str, Any]] = {}
38
+ status_rank = {"lost": 0, "reordered": 1, "delivered": 2}
39
+ for record in records:
40
+ topic = str(record.get("topic") or "")
41
+ source = str(record.get("source") or "")
42
+ seq = int(record["seq"])
43
+ key = (source, topic, seq)
44
+ current = joined.get(key)
45
+ if current is None:
46
+ joined[key] = dict(record)
47
+ continue
48
+ if status_rank.get(str(record.get("status")), -1) > status_rank.get(str(current.get("status")), -1):
49
+ current["status"] = record.get("status")
50
+ for field, value in record.items():
51
+ if current.get(field) is None and value is not None:
52
+ current[field] = value
53
+ elif field == "sections" and isinstance(value, dict):
54
+ sections = current.setdefault("sections", {})
55
+ for section, section_value in value.items():
56
+ if sections.get(section) is None and section_value is not None:
57
+ sections[section] = section_value
58
+ return [joined[key] for key in sorted(joined)]
59
+
60
+
61
+ def filter_transit_records_by_publish_window(
62
+ records: Iterable[dict[str, Any]],
63
+ *,
64
+ start_s: float,
65
+ end_s: float,
66
+ time_field: str = "t_wrap",
67
+ ) -> list[dict[str, Any]]:
68
+ """Keep records whose publish time falls inside a measurement window.
69
+
70
+ Lost records do not carry timestamps. For each source/topic/target stream,
71
+ keep lost sequence records only when they are bounded by delivered/reordered
72
+ records inside the window. This excludes startup and teardown sequence gaps
73
+ without hiding losses that happened during the measured interval.
74
+ """
75
+ if end_s < start_s:
76
+ raise ValueError("end_s must be >= start_s")
77
+ grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
78
+ for record in join_transit_records(records):
79
+ key = (
80
+ str(record.get("source") or ""),
81
+ str(record.get("target") or ""),
82
+ str(record.get("topic") or ""),
83
+ )
84
+ grouped.setdefault(key, []).append(record)
85
+
86
+ filtered: list[dict[str, Any]] = []
87
+ for key in sorted(grouped):
88
+ stream = grouped[key]
89
+ timed: list[dict[str, Any]] = []
90
+ for record in stream:
91
+ stamp = record.get(time_field)
92
+ if stamp is None:
93
+ continue
94
+ if start_s <= float(stamp) <= end_s:
95
+ timed.append(record)
96
+ if not timed:
97
+ continue
98
+ seq_min = min(int(record["seq"]) for record in timed)
99
+ seq_max = max(int(record["seq"]) for record in timed)
100
+ for record in stream:
101
+ if record.get("status") == "lost":
102
+ seq = int(record["seq"])
103
+ if seq_min <= seq <= seq_max:
104
+ filtered.append(record)
105
+ continue
106
+ if record in timed:
107
+ filtered.append(record)
108
+ return sorted(
109
+ filtered,
110
+ key=lambda record: (
111
+ str(record.get("source") or ""),
112
+ str(record.get("target") or ""),
113
+ str(record.get("topic") or ""),
114
+ int(record["seq"]),
115
+ ),
116
+ )
117
+
118
+
119
+ def _record_latency_ms(record: dict[str, Any]) -> float | None:
120
+ sections = record.get("sections")
121
+ if not isinstance(sections, dict):
122
+ return None
123
+ value = sections.get("ota_hop_ms")
124
+ if value is None:
125
+ value = sections.get("ota_hop_uncorrected_ms")
126
+ return None if value is None else float(value)
127
+
128
+
129
+ def _distribution_ms(values: list[float]) -> dict[str, float | None]:
130
+ """Full distribution of one per-message metric (ms): spread ends and quantiles."""
131
+ if not values:
132
+ return dict.fromkeys(("min", "mean", "p50", "p95", "p99", "max", "std"))
133
+ mean = sum(values) / len(values)
134
+ std = math.sqrt(sum((value - mean) ** 2 for value in values) / len(values))
135
+ return {
136
+ "min": round(min(values), 3),
137
+ "mean": round(mean, 3),
138
+ "p50": _percentile(values, 0.50),
139
+ "p95": _percentile(values, 0.95),
140
+ "p99": _percentile(values, 0.99),
141
+ "max": round(max(values), 3),
142
+ "std": round(std, 3),
143
+ }
144
+
145
+
146
+ def _latency_trend_ms(
147
+ topic_records: list[dict[str, Any]],
148
+ *,
149
+ time_field: str = "t_wrap",
150
+ min_points: int = 6,
151
+ ) -> dict[str, float | None]:
152
+ """Median latency of the first vs the last third of the run (by publish time).
153
+
154
+ A positive ``delta`` means the median rose over the course of the run — the
155
+ bufferbloat signature a whole-run p50 hides.
156
+ """
157
+ empty: dict[str, float | None] = dict.fromkeys(("first_third_p50", "last_third_p50", "delta"))
158
+ stamped = sorted(
159
+ (float(record[time_field]), latency)
160
+ for record in topic_records
161
+ if record.get(time_field) is not None and (latency := _record_latency_ms(record)) is not None
162
+ )
163
+ if len(stamped) < min_points:
164
+ return empty
165
+ span = stamped[-1][0] - stamped[0][0]
166
+ if span <= 0.0:
167
+ return empty
168
+ first_p50 = _percentile([latency for stamp, latency in stamped if stamp <= stamped[0][0] + span / 3.0], 0.50)
169
+ last_p50 = _percentile([latency for stamp, latency in stamped if stamp >= stamped[-1][0] - span / 3.0], 0.50)
170
+ if first_p50 is None or last_p50 is None:
171
+ return empty
172
+ return {"first_third_p50": first_p50, "last_third_p50": last_p50, "delta": round(last_p50 - first_p50, 3)}
173
+
174
+
175
+ def _nominal_send_period_ms(topic_records: list[dict[str, Any]], *, time_field: str = "t_wrap") -> float | None:
176
+ """Median publish spacing per sequence step — the send cadence of the stream."""
177
+ stamped = sorted(
178
+ (int(record["seq"]), float(record[time_field]))
179
+ for record in topic_records
180
+ if record.get(time_field) is not None
181
+ )
182
+ periods = [
183
+ (t1 - t0) / (s1 - s0) for (s0, t0), (s1, t1) in zip(stamped, stamped[1:], strict=False) if s1 > s0 and t1 >= t0
184
+ ]
185
+ if not periods:
186
+ return None
187
+ ordered = sorted(periods)
188
+ return round(ordered[len(ordered) // 2] * 1000.0, 3)
189
+
190
+
191
+ # Arrival-spacing regimes relative to the nominal send period: a gap well below
192
+ # nominal means messages arrived bunched (a delayed message released together with
193
+ # the one it head-of-line-blocked); a gap well above nominal means the stream stalled.
194
+ BUNCHED_GAP_FRACTION = 0.5
195
+ STALLED_GAP_FRACTION = 1.5
196
+
197
+
198
+ def _arrival_spacing_ms(topic_records: list[dict[str, Any]], *, time_field: str = "t_wrap") -> dict[str, Any]:
199
+ """Distribution of receiver inter-arrival gaps, plus bunching/stall shares.
200
+
201
+ ``stall_then_bunch_pct`` is the share of stalled gaps immediately followed by
202
+ a bunched gap — equidistantly sent messages arriving as "long wait, then a
203
+ clump", the head-of-line-blocking signature.
204
+ """
205
+ gaps = [float(record["inter_arrival_ms"]) for record in topic_records if record.get("inter_arrival_ms") is not None]
206
+ stats: dict[str, Any] = _distribution_ms(gaps)
207
+ stats["p05"] = _percentile(gaps, 0.05)
208
+ nominal_ms = _nominal_send_period_ms(topic_records, time_field=time_field)
209
+ stats["nominal_period_ms"] = nominal_ms
210
+ if not gaps or not nominal_ms:
211
+ stats.update(dict.fromkeys(("bunched_pct", "stalled_pct", "stall_then_bunch_pct")))
212
+ return stats
213
+ bunched = [gap < BUNCHED_GAP_FRACTION * nominal_ms for gap in gaps]
214
+ stalled = [gap > STALLED_GAP_FRACTION * nominal_ms for gap in gaps]
215
+ stats["bunched_pct"] = round(100.0 * sum(bunched) / len(gaps), 3)
216
+ stats["stalled_pct"] = round(100.0 * sum(stalled) / len(gaps), 3)
217
+ followers = [bunched[index + 1] for index in range(len(gaps) - 1) if stalled[index]]
218
+ stats["stall_then_bunch_pct"] = round(100.0 * sum(followers) / len(followers), 3) if followers else None
219
+ return stats
220
+
221
+
222
+ def summarize_transit_records(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
223
+ by_topic: dict[str, list[dict[str, Any]]] = {}
224
+ for record in join_transit_records(records):
225
+ topic = str(record.get("topic") or "")
226
+ source = str(record.get("source") or "")
227
+ target = str(record.get("target") or "")
228
+ label = f"{source}->{target}:{topic}" if source or target else topic
229
+ by_topic.setdefault(label, []).append(record)
230
+
231
+ topics: dict[str, Any] = {}
232
+ for topic, topic_records in sorted(by_topic.items()):
233
+ lost = sum(record.get("status") == "lost" for record in topic_records)
234
+ reordered = sum(record.get("status") == "reordered" for record in topic_records)
235
+ delivered = len(topic_records) - lost
236
+ ota = [latency for record in topic_records if (latency := _record_latency_ms(record)) is not None]
237
+ jitter = [float(record["jitter_ms"]) for record in topic_records if record.get("jitter_ms") is not None]
238
+ topics[topic] = {
239
+ "expected": len(topic_records),
240
+ "delivered": delivered,
241
+ "lost": lost,
242
+ "loss_pct": round(100.0 * lost / len(topic_records), 3) if topic_records else 0.0,
243
+ "reordered": reordered,
244
+ "ota_hop_ms": _distribution_ms(ota),
245
+ "ota_hop_trend_ms": _latency_trend_ms(topic_records),
246
+ "jitter_ms": _distribution_ms(jitter),
247
+ "inter_arrival_ms": _arrival_spacing_ms(topic_records),
248
+ }
249
+ return {"schema_version": 1, "topics": topics}