mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-07 01:13:11 +02:00
feat: Add LBT diagnostics endpoint with correlation analysis
- Implemented `lbt_diagnostics` API endpoint to return aggregated Listen Before Talk (LBT) diagnostics aligned with RF metrics. - Introduced methods for calculating Pearson correlation coefficients and auto-bucket sizing for diagnostics. - Enhanced data aggregation logic in `StorageCollector` for LBT diagnostics. - Updated OpenAPI specification to include new endpoint and response schemas. - Added comprehensive unit tests for LBT diagnostics, including validation of correlation calculations and data integrity.
This commit is contained in:
@@ -1743,6 +1743,178 @@ def test_metrics_graph_data_includes_policy_events(cherrypy_ctx):
|
||||
assert series_by_type["policy_events"]["data"] == [[100000, 1], [160000, 3], [220000, 2]]
|
||||
|
||||
|
||||
def test_lbt_diagnostics_aligns_with_rrd_and_returns_correlations(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
|
||||
storage = SimpleNamespace(
|
||||
get_lbt_diagnostics=MagicMock(
|
||||
return_value={
|
||||
"start_time": 60,
|
||||
"end_time": 240,
|
||||
"bucket_seconds": 60,
|
||||
"summary": {
|
||||
"total_transmissions": 10,
|
||||
"total_attempts": 14,
|
||||
"first_attempt_success": 7,
|
||||
"retry_packets": 3,
|
||||
"retry_rate_pct": 30.0,
|
||||
"first_attempt_success_rate_pct": 70.0,
|
||||
"avg_attempts": 1.4,
|
||||
"median_attempts": 1.0,
|
||||
"p95_attempts": 3.0,
|
||||
"max_attempts": 4,
|
||||
"attempts_1": 7,
|
||||
"attempts_2": 2,
|
||||
"attempts_3": 1,
|
||||
"attempts_4_plus": 0,
|
||||
"attempts_3_plus": 1,
|
||||
"attempts_3_plus_pct": 10.0,
|
||||
"attempts_4_plus_pct": 0.0,
|
||||
"failed_transmissions": 0,
|
||||
"busy_channel_events": 3,
|
||||
"severe_contention_count": 0,
|
||||
"severe_contention_pct": 0.0,
|
||||
"severe_attempt_threshold": 4,
|
||||
"has_lbt_data": True,
|
||||
"worst_bucket": {
|
||||
"timestamp": 120,
|
||||
"retry_rate_pct": 50.0,
|
||||
"attempts_3_plus_pct": 20.0,
|
||||
"max_attempts": 3,
|
||||
"transmissions": 5,
|
||||
},
|
||||
},
|
||||
"buckets": [
|
||||
{
|
||||
"timestamp": 60,
|
||||
"transmissions": 3,
|
||||
"total_attempts": 3,
|
||||
"first_attempt_success": 3,
|
||||
"retry_packets": 0,
|
||||
"retry_rate_pct": 0.0,
|
||||
"first_attempt_success_rate_pct": 100.0,
|
||||
"avg_attempts": 1.0,
|
||||
"median_attempts": 1.0,
|
||||
"p95_attempts": 1.0,
|
||||
"max_attempts": 1,
|
||||
"attempts_1": 3,
|
||||
"attempts_2": 0,
|
||||
"attempts_3": 0,
|
||||
"attempts_4_plus": 0,
|
||||
"attempts_3_plus": 0,
|
||||
"attempts_3_plus_pct": 0.0,
|
||||
"attempts_4_plus_pct": 0.0,
|
||||
"failed_transmissions": 0,
|
||||
"busy_channel_events": 0,
|
||||
"severe_contention_count": 0,
|
||||
"severe_contention_pct": 0.0,
|
||||
},
|
||||
{
|
||||
"timestamp": 120,
|
||||
"transmissions": 5,
|
||||
"total_attempts": 8,
|
||||
"first_attempt_success": 2,
|
||||
"retry_packets": 3,
|
||||
"retry_rate_pct": 60.0,
|
||||
"first_attempt_success_rate_pct": 40.0,
|
||||
"avg_attempts": 1.6,
|
||||
"median_attempts": 2.0,
|
||||
"p95_attempts": 3.0,
|
||||
"max_attempts": 3,
|
||||
"attempts_1": 2,
|
||||
"attempts_2": 2,
|
||||
"attempts_3": 1,
|
||||
"attempts_4_plus": 0,
|
||||
"attempts_3_plus": 1,
|
||||
"attempts_3_plus_pct": 20.0,
|
||||
"attempts_4_plus_pct": 0.0,
|
||||
"failed_transmissions": 0,
|
||||
"busy_channel_events": 3,
|
||||
"severe_contention_count": 0,
|
||||
"severe_contention_pct": 0.0,
|
||||
},
|
||||
],
|
||||
"packet_types": [
|
||||
{
|
||||
"packet_type": 1,
|
||||
"packet_type_label": "Response (RESPONSE)",
|
||||
"transmissions": 8,
|
||||
"retry_packets": 3,
|
||||
"retry_rate_pct": 37.5,
|
||||
}
|
||||
],
|
||||
"packet_type_buckets": [
|
||||
{
|
||||
"timestamp": 120,
|
||||
"packet_type": 1,
|
||||
"packet_type_label": "Response (RESPONSE)",
|
||||
"transmissions": 5,
|
||||
"total_attempts": 8,
|
||||
"first_attempt_success": 2,
|
||||
"retry_packets": 3,
|
||||
"retry_rate_pct": 60.0,
|
||||
"first_attempt_success_rate_pct": 40.0,
|
||||
"avg_attempts": 1.6,
|
||||
"attempts_1": 2,
|
||||
"attempts_2": 2,
|
||||
"attempts_3": 1,
|
||||
"attempts_4_plus": 0,
|
||||
"attempts_3_plus": 1,
|
||||
"attempts_3_plus_pct": 20.0,
|
||||
"max_attempts": 3,
|
||||
"failed_transmissions": 0,
|
||||
"severe_contention_count": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
get_rrd_data=MagicMock(
|
||||
return_value={
|
||||
"timestamps": [100, 160, 220],
|
||||
"metrics": {
|
||||
"rx_count": [100, 110, 125],
|
||||
"tx_count": [50, 55, 70],
|
||||
"drop_count": [10, 11, 14],
|
||||
"avg_rssi": [-80.0, -90.0, -95.0],
|
||||
"avg_snr": [8.0, 2.0, -1.0],
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
_attach_storage(api, storage)
|
||||
|
||||
out = api.lbt_diagnostics(hours="24", bucket_seconds="60", severe_attempt_threshold="4")
|
||||
assert out["success"] is True
|
||||
assert out["data"]["bucket_seconds"] == 60
|
||||
assert out["data"]["severe_attempt_threshold"] == 4
|
||||
|
||||
buckets = out["data"]["buckets"]
|
||||
assert len(buckets) == 2
|
||||
assert "rf" in buckets[0]
|
||||
assert buckets[0]["rf"]["traffic_volume"] >= 0
|
||||
assert buckets[0]["rf"]["avg_snr"] is not None
|
||||
|
||||
correlations = out["data"]["correlations"]
|
||||
assert "retry_rate_vs_avg_snr" in correlations
|
||||
assert "retry_rate_vs_traffic_volume" in correlations
|
||||
assert "sample_count" in correlations["retry_rate_vs_avg_snr"]
|
||||
|
||||
assert out["data"]["packet_types"][0]["packet_type"] == 1
|
||||
assert out["data"]["packet_type_buckets"][0]["packet_type_label"] == "Response (RESPONSE)"
|
||||
assert "rf" in out["data"]["packet_type_buckets"][0]
|
||||
|
||||
|
||||
def test_lbt_diagnostics_rejects_unbounded_large_time_range(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
_attach_storage(api, SimpleNamespace())
|
||||
|
||||
out = api.lbt_diagnostics(start_timestamp="0", end_timestamp=str(200 * 3600))
|
||||
assert out["success"] is False
|
||||
assert "Max range" in out["error"]
|
||||
|
||||
|
||||
def test_advert_contact_and_rate_limit_stats_endpoints(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
|
||||
@@ -361,6 +361,10 @@ def test_sync_transport_keys_validation_and_tree_apply(tmp_path, monkeypatch):
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_sync_transport_keys_parent_and_tree_apply(tmp_path, monkeypatch):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="Parent node 'missing'"):
|
||||
h.sync_transport_keys(
|
||||
[
|
||||
@@ -405,3 +409,162 @@ def test_sync_transport_keys_validation_and_tree_apply(tmp_path, monkeypatch):
|
||||
assert rows[1][2] == "deny"
|
||||
assert rows[1][3] == "GEN-KEY"
|
||||
assert rows[1][4] == rows[0][0]
|
||||
|
||||
|
||||
def test_get_lbt_diagnostics_aggregates_retry_distribution_and_summary(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
packets = [
|
||||
{
|
||||
"timestamp": 10.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": True,
|
||||
"packet_hash": "lbt-1",
|
||||
"lbt_attempts": 0,
|
||||
"lbt_channel_busy": False,
|
||||
},
|
||||
{
|
||||
"timestamp": 20.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": True,
|
||||
"packet_hash": "lbt-2",
|
||||
"lbt_attempts": 1,
|
||||
"lbt_channel_busy": True,
|
||||
},
|
||||
{
|
||||
"timestamp": 30.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": True,
|
||||
"packet_hash": "lbt-3",
|
||||
"lbt_attempts": 2,
|
||||
"lbt_channel_busy": True,
|
||||
},
|
||||
{
|
||||
"timestamp": 40.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": False,
|
||||
"drop_reason": "TX failed",
|
||||
"packet_hash": "lbt-4",
|
||||
"lbt_attempts": 4,
|
||||
"lbt_channel_busy": True,
|
||||
},
|
||||
{
|
||||
# Excluded from TX-path diagnostics by filter.
|
||||
"timestamp": 50.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": False,
|
||||
"drop_reason": "Duplicate",
|
||||
"packet_hash": "lbt-excluded",
|
||||
"lbt_attempts": 0,
|
||||
"lbt_channel_busy": False,
|
||||
},
|
||||
{
|
||||
"timestamp": 70.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 8,
|
||||
"transmitted": True,
|
||||
"packet_hash": "lbt-5",
|
||||
"lbt_attempts": 0,
|
||||
"lbt_channel_busy": False,
|
||||
},
|
||||
]
|
||||
|
||||
for record in packets:
|
||||
h.store_packet(record)
|
||||
|
||||
out = h.get_lbt_diagnostics(
|
||||
start_timestamp=0,
|
||||
end_timestamp=180,
|
||||
bucket_seconds=60,
|
||||
severe_attempt_threshold=4,
|
||||
)
|
||||
|
||||
summary = out["summary"]
|
||||
assert summary["total_transmissions"] == 5
|
||||
assert summary["total_attempts"] == 12
|
||||
assert summary["first_attempt_success"] == 2
|
||||
assert summary["retry_packets"] == 3
|
||||
assert summary["retry_rate_pct"] == pytest.approx(60.0)
|
||||
assert summary["avg_attempts"] == pytest.approx(2.4)
|
||||
assert summary["max_attempts"] == 5
|
||||
assert summary["median_attempts"] == pytest.approx(2.0)
|
||||
assert summary["p95_attempts"] == pytest.approx(5.0)
|
||||
assert summary["attempts_1"] == 2
|
||||
assert summary["attempts_2"] == 1
|
||||
assert summary["attempts_3"] == 1
|
||||
assert summary["attempts_4_plus"] == 1
|
||||
assert summary["attempts_3_plus"] == 2
|
||||
assert summary["failed_transmissions"] == 1
|
||||
assert summary["busy_channel_events"] == 3
|
||||
assert summary["severe_contention_count"] == 1
|
||||
assert summary["has_lbt_data"] is True
|
||||
assert summary["worst_bucket"] is not None
|
||||
assert summary["worst_bucket"]["timestamp"] == 0
|
||||
|
||||
buckets = {int(b["timestamp"]): b for b in out["buckets"]}
|
||||
first = buckets[0]
|
||||
assert first["transmissions"] == 4
|
||||
assert first["retry_packets"] == 3
|
||||
assert first["retry_rate_pct"] == pytest.approx(75.0)
|
||||
assert first["first_attempt_success_rate_pct"] == pytest.approx(25.0)
|
||||
assert first["attempts_4_plus"] == 1
|
||||
assert first["severe_contention_count"] == 1
|
||||
assert first["failed_transmissions"] == 1
|
||||
|
||||
second = buckets[60]
|
||||
assert second["transmissions"] == 1
|
||||
assert second["retry_packets"] == 0
|
||||
assert second["retry_rate_pct"] == pytest.approx(0.0)
|
||||
assert second["avg_attempts"] == pytest.approx(1.0)
|
||||
|
||||
packet_types = out["packet_types"]
|
||||
assert len(packet_types) == 1
|
||||
assert packet_types[0]["packet_type"] == 1
|
||||
assert packet_types[0]["transmissions"] == 5
|
||||
assert packet_types[0]["retry_packets"] == 3
|
||||
|
||||
packet_type_buckets = out["packet_type_buckets"]
|
||||
assert len(packet_type_buckets) == 2
|
||||
first_type_bucket = packet_type_buckets[0]
|
||||
assert first_type_bucket["packet_type"] == 1
|
||||
assert first_type_bucket["timestamp"] == 0
|
||||
assert first_type_bucket["retry_rate_pct"] == pytest.approx(75.0)
|
||||
assert first_type_bucket["attempts_3_plus_pct"] == pytest.approx(50.0)
|
||||
|
||||
|
||||
def test_get_lbt_diagnostics_empty_range_preserves_no_data_distinction(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
out = h.get_lbt_diagnostics(
|
||||
start_timestamp=0,
|
||||
end_timestamp=180,
|
||||
bucket_seconds=60,
|
||||
severe_attempt_threshold=4,
|
||||
)
|
||||
|
||||
summary = out["summary"]
|
||||
assert summary["total_transmissions"] == 0
|
||||
assert summary["retry_rate_pct"] is None
|
||||
assert summary["first_attempt_success_rate_pct"] is None
|
||||
assert summary["avg_attempts"] is None
|
||||
assert summary["has_lbt_data"] is False
|
||||
|
||||
assert len(out["buckets"]) >= 3
|
||||
for bucket in out["buckets"]:
|
||||
assert bucket["transmissions"] == 0
|
||||
assert bucket["retry_rate_pct"] is None
|
||||
assert bucket["first_attempt_success_rate_pct"] is None
|
||||
assert bucket["avg_attempts"] is None
|
||||
assert out["packet_types"] == []
|
||||
assert out["packet_type_buckets"] == []
|
||||
|
||||
Reference in New Issue
Block a user