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
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}
|