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:
Lloyd
2026-07-10 13:36:49 +01:00
parent 95500ece09
commit b2eb45b199
6 changed files with 1593 additions and 0 deletions
+519
View File
@@ -1,6 +1,7 @@
import base64
import json
import logging
import math
import secrets
import sqlite3
import threading
@@ -944,6 +945,524 @@ class SQLiteHandler:
logger.error(f"Failed to get policy event counts: {e}")
return []
def get_lbt_diagnostics(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 300,
severe_attempt_threshold: int = 4,
) -> dict:
"""Return aggregated LBT diagnostics for TX-path packets.
LBT metadata in packets is persisted as "extra attempts/backoffs" where:
- lbt_attempts == 0 means first CAD/LBT check was clear
- total attempts/checks ~= lbt_attempts + 1
This method avoids returning raw packet rows and instead returns
bucketed aggregates + summary metrics for efficient dashboard refreshes.
"""
def _weighted_percentile(attempt_counts: dict, q: float) -> Optional[float]:
total = sum(int(v) for v in attempt_counts.values())
if total <= 0:
return None
q = max(0.0, min(1.0, float(q)))
# Use nearest-rank percentile so p95 on sparse samples doesn't
# systematically under-report tail attempts.
rank = max(1, int(math.ceil(total * q)))
running = 0
for attempt in sorted(int(k) for k in attempt_counts.keys()):
running += int(attempt_counts.get(attempt, 0))
if running >= rank:
return float(attempt)
return float(max(int(k) for k in attempt_counts.keys()))
def _packet_type_name(pkt_type: int) -> str:
try:
from openhop_core.protocol.utils import PAYLOAD_TYPES as _PT
labels = {
"REQ": "Request",
"RESPONSE": "Response",
"TXT_MSG": "Plain Text Message",
"ACK": "Acknowledgment",
"ADVERT": "Node Advertisement",
"GRP_TXT": "Group Text Message",
"GRP_DATA": "Group Datagram",
"ANON_REQ": "Anonymous Request",
"PATH": "Returned Path",
"TRACE": "Trace",
"MULTIPART": "Multi-part Packet",
"CONTROL": "Control",
"RAW_CUSTOM": "Custom Packet",
}
code = _PT.get(pkt_type)
if not code:
return (
f"Reserved Type {pkt_type}" if 0 <= pkt_type <= 15 else f"Type {pkt_type}"
)
return f"{labels.get(code, code.replace('_', ' ').title())} ({code})"
except Exception:
return f"Reserved Type {pkt_type}" if 0 <= pkt_type <= 15 else f"Type {pkt_type}"
try:
bucket_seconds = max(60, min(int(bucket_seconds), 3600))
severe_attempt_threshold = max(2, int(severe_attempt_threshold))
if end_timestamp < start_timestamp:
start_timestamp, end_timestamp = end_timestamp, start_timestamp
tx_filter = "(transmitted = 1 OR lbt_attempts > 0 OR drop_reason LIKE 'TX failed%')"
with self._connect() as conn:
conn.row_factory = sqlite3.Row
aggregate_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total,
CASE WHEN transmitted = 1 THEN 1 ELSE 0 END AS tx_success,
CASE
WHEN transmitted = 0 AND drop_reason LIKE 'TX failed%' THEN 1
ELSE 0
END AS failed_tx,
CASE WHEN COALESCE(lbt_channel_busy, 0) = 1 THEN 1 ELSE 0 END AS busy
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT
bucket_ts,
COUNT(*) AS transmissions,
SUM(attempts_total) AS total_attempts,
SUM(CASE WHEN attempts_total = 1 THEN 1 ELSE 0 END) AS attempts_1,
SUM(CASE WHEN attempts_total = 2 THEN 1 ELSE 0 END) AS attempts_2,
SUM(CASE WHEN attempts_total = 3 THEN 1 ELSE 0 END) AS attempts_3,
SUM(CASE WHEN attempts_total >= 4 THEN 1 ELSE 0 END) AS attempts_4_plus,
SUM(CASE WHEN attempts_total > 1 THEN 1 ELSE 0 END) AS retry_packets,
SUM(CASE WHEN tx_success = 1 AND attempts_total = 1 THEN 1 ELSE 0 END) AS first_attempt_success,
SUM(failed_tx) AS failed_transmissions,
SUM(busy) AS busy_channel_events,
SUM(CASE WHEN attempts_total >= ? THEN 1 ELSE 0 END) AS severe_contention_count,
MAX(attempts_total) AS max_attempts
FROM tx_packets
GROUP BY bucket_ts
ORDER BY bucket_ts ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
severe_attempt_threshold,
),
).fetchall()
dist_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT bucket_ts, attempts_total, COUNT(*) AS cnt
FROM tx_packets
GROUP BY bucket_ts, attempts_total
ORDER BY bucket_ts ASC, attempts_total ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
),
).fetchall()
type_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
type AS packet_type,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total,
CASE WHEN transmitted = 1 THEN 1 ELSE 0 END AS tx_success,
CASE
WHEN transmitted = 0 AND drop_reason LIKE 'TX failed%' THEN 1
ELSE 0
END AS failed_tx
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT
bucket_ts,
packet_type,
COUNT(*) AS transmissions,
SUM(attempts_total) AS total_attempts,
SUM(CASE WHEN attempts_total = 1 THEN 1 ELSE 0 END) AS attempts_1,
SUM(CASE WHEN attempts_total = 2 THEN 1 ELSE 0 END) AS attempts_2,
SUM(CASE WHEN attempts_total = 3 THEN 1 ELSE 0 END) AS attempts_3,
SUM(CASE WHEN attempts_total >= 4 THEN 1 ELSE 0 END) AS attempts_4_plus,
SUM(CASE WHEN attempts_total > 1 THEN 1 ELSE 0 END) AS retry_packets,
SUM(CASE WHEN tx_success = 1 AND attempts_total = 1 THEN 1 ELSE 0 END) AS first_attempt_success,
SUM(failed_tx) AS failed_transmissions,
SUM(CASE WHEN attempts_total >= ? THEN 1 ELSE 0 END) AS severe_contention_count,
MAX(attempts_total) AS max_attempts
FROM tx_packets
GROUP BY bucket_ts, packet_type
ORDER BY bucket_ts ASC, packet_type ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
severe_attempt_threshold,
),
).fetchall()
dist_by_bucket: dict = {}
overall_dist: dict = {}
for row in dist_rows:
bucket_ts = int(row["bucket_ts"])
attempt = int(row["attempts_total"])
count = int(row["cnt"])
bucket_dist = dist_by_bucket.setdefault(bucket_ts, {})
bucket_dist[attempt] = bucket_dist.get(attempt, 0) + count
overall_dist[attempt] = overall_dist.get(attempt, 0) + count
bucket_map: dict = {}
start_bucket = int(float(start_timestamp) // bucket_seconds) * bucket_seconds
end_bucket = int(float(end_timestamp) // bucket_seconds) * bucket_seconds
for bucket_ts in range(start_bucket, end_bucket + 1, bucket_seconds):
bucket_map[bucket_ts] = {
"timestamp": bucket_ts,
"transmissions": 0,
"total_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"retry_packets": 0,
"first_attempt_success": 0,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"max_attempts": 0,
}
for row in aggregate_rows:
bucket_ts = int(row["bucket_ts"])
if bucket_ts not in bucket_map:
bucket_map[bucket_ts] = {
"timestamp": bucket_ts,
"transmissions": 0,
"total_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"retry_packets": 0,
"first_attempt_success": 0,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"max_attempts": 0,
}
bucket_map[bucket_ts].update(
{
"transmissions": int(row["transmissions"] or 0),
"total_attempts": int(row["total_attempts"] or 0),
"attempts_1": int(row["attempts_1"] or 0),
"attempts_2": int(row["attempts_2"] or 0),
"attempts_3": int(row["attempts_3"] or 0),
"attempts_4_plus": int(row["attempts_4_plus"] or 0),
"retry_packets": int(row["retry_packets"] or 0),
"first_attempt_success": int(row["first_attempt_success"] or 0),
"failed_transmissions": int(row["failed_transmissions"] or 0),
"busy_channel_events": int(row["busy_channel_events"] or 0),
"severe_contention_count": int(row["severe_contention_count"] or 0),
"max_attempts": int(row["max_attempts"] or 0),
}
)
buckets = []
for bucket_ts in sorted(bucket_map.keys()):
bucket = bucket_map[bucket_ts]
transmissions = int(bucket["transmissions"])
total_attempts = int(bucket["total_attempts"])
attempts_3_plus = int(bucket["attempts_3"] + bucket["attempts_4_plus"])
median_attempts = _weighted_percentile(dist_by_bucket.get(bucket_ts, {}), 0.5)
p95_attempts = _weighted_percentile(dist_by_bucket.get(bucket_ts, {}), 0.95)
retry_rate_pct = None
first_attempt_success_rate_pct = None
avg_attempts = None
attempts_3_plus_pct = None
attempts_4_plus_pct = None
severe_contention_pct = None
if transmissions > 0:
retry_rate_pct = (bucket["retry_packets"] * 100.0) / transmissions
first_attempt_success_rate_pct = (
bucket["first_attempt_success"] * 100.0
) / transmissions
avg_attempts = total_attempts / transmissions
attempts_3_plus_pct = (attempts_3_plus * 100.0) / transmissions
attempts_4_plus_pct = (bucket["attempts_4_plus"] * 100.0) / transmissions
severe_contention_pct = (
bucket["severe_contention_count"] * 100.0
) / transmissions
buckets.append(
{
"timestamp": bucket_ts,
"transmissions": transmissions,
"total_attempts": total_attempts,
"first_attempt_success": int(bucket["first_attempt_success"]),
"retry_packets": int(bucket["retry_packets"]),
"retry_rate_pct": retry_rate_pct,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct,
"avg_attempts": avg_attempts,
"median_attempts": median_attempts,
"p95_attempts": p95_attempts,
"max_attempts": int(bucket["max_attempts"]),
"attempts_1": int(bucket["attempts_1"]),
"attempts_2": int(bucket["attempts_2"]),
"attempts_3": int(bucket["attempts_3"]),
"attempts_4_plus": int(bucket["attempts_4_plus"]),
"attempts_3_plus": int(attempts_3_plus),
"attempts_3_plus_pct": attempts_3_plus_pct,
"attempts_4_plus_pct": attempts_4_plus_pct,
"failed_transmissions": int(bucket["failed_transmissions"]),
"busy_channel_events": int(bucket["busy_channel_events"]),
"severe_contention_count": int(bucket["severe_contention_count"]),
"severe_contention_pct": severe_contention_pct,
}
)
total_transmissions = int(sum(b["transmissions"] for b in buckets))
total_attempts = int(sum(b["total_attempts"] for b in buckets))
first_attempt_success = int(sum(b["first_attempt_success"] for b in buckets))
retry_packets = int(sum(b["retry_packets"] for b in buckets))
attempts_1 = int(sum(b["attempts_1"] for b in buckets))
attempts_2 = int(sum(b["attempts_2"] for b in buckets))
attempts_3 = int(sum(b["attempts_3"] for b in buckets))
attempts_4_plus = int(sum(b["attempts_4_plus"] for b in buckets))
attempts_3_plus = int(attempts_3 + attempts_4_plus)
failed_transmissions = int(sum(b["failed_transmissions"] for b in buckets))
busy_channel_events = int(sum(b["busy_channel_events"] for b in buckets))
severe_contention_count = int(sum(b["severe_contention_count"] for b in buckets))
max_attempts = int(max([b["max_attempts"] for b in buckets], default=0))
retry_rate_pct = None
first_attempt_success_rate_pct = None
avg_attempts = None
attempts_3_plus_pct = None
attempts_4_plus_pct = None
severe_contention_pct = None
if total_transmissions > 0:
retry_rate_pct = (retry_packets * 100.0) / total_transmissions
first_attempt_success_rate_pct = (
first_attempt_success * 100.0
) / total_transmissions
avg_attempts = total_attempts / total_transmissions
attempts_3_plus_pct = (attempts_3_plus * 100.0) / total_transmissions
attempts_4_plus_pct = (attempts_4_plus * 100.0) / total_transmissions
severe_contention_pct = (severe_contention_count * 100.0) / total_transmissions
worst_bucket = None
scored_buckets = [
b
for b in buckets
if int(b.get("transmissions", 0)) > 0 and b.get("retry_rate_pct") is not None
]
if scored_buckets:
worst = max(
scored_buckets, key=lambda item: float(item.get("retry_rate_pct") or 0.0)
)
worst_bucket = {
"timestamp": int(worst["timestamp"]),
"retry_rate_pct": float(worst.get("retry_rate_pct") or 0.0),
"attempts_3_plus_pct": float(worst.get("attempts_3_plus_pct") or 0.0),
"max_attempts": int(worst.get("max_attempts") or 0),
"transmissions": int(worst.get("transmissions") or 0),
}
summary = {
"total_transmissions": total_transmissions,
"total_attempts": total_attempts,
"first_attempt_success": first_attempt_success,
"retry_packets": retry_packets,
"retry_rate_pct": retry_rate_pct,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct,
"avg_attempts": avg_attempts,
"median_attempts": _weighted_percentile(overall_dist, 0.5),
"p95_attempts": _weighted_percentile(overall_dist, 0.95),
"max_attempts": max_attempts,
"attempts_1": attempts_1,
"attempts_2": attempts_2,
"attempts_3": attempts_3,
"attempts_4_plus": attempts_4_plus,
"attempts_3_plus": attempts_3_plus,
"attempts_3_plus_pct": attempts_3_plus_pct,
"attempts_4_plus_pct": attempts_4_plus_pct,
"failed_transmissions": failed_transmissions,
"busy_channel_events": busy_channel_events,
"severe_contention_count": severe_contention_count,
"severe_contention_pct": severe_contention_pct,
"severe_attempt_threshold": severe_attempt_threshold,
"has_lbt_data": total_transmissions > 0,
"worst_bucket": worst_bucket,
}
packet_type_totals: dict = {}
packet_type_buckets = []
for row in type_rows:
bucket_ts = int(row["bucket_ts"])
packet_type = int(row["packet_type"] if row["packet_type"] is not None else -1)
transmissions = int(row["transmissions"] or 0)
total_attempts_for_type = int(row["total_attempts"] or 0)
attempts_3_plus = int((row["attempts_3"] or 0) + (row["attempts_4_plus"] or 0))
retry_rate_pct_for_type = None
first_attempt_success_rate_pct_for_type = None
avg_attempts_for_type = None
attempts_3_plus_pct_for_type = None
if transmissions > 0:
retry_rate_pct_for_type = (
int(row["retry_packets"] or 0) * 100.0
) / transmissions
first_attempt_success_rate_pct_for_type = (
int(row["first_attempt_success"] or 0) * 100.0
) / transmissions
avg_attempts_for_type = total_attempts_for_type / transmissions
attempts_3_plus_pct_for_type = (attempts_3_plus * 100.0) / transmissions
packet_type_buckets.append(
{
"timestamp": bucket_ts,
"packet_type": packet_type,
"packet_type_label": _packet_type_name(packet_type),
"transmissions": transmissions,
"total_attempts": total_attempts_for_type,
"first_attempt_success": int(row["first_attempt_success"] or 0),
"retry_packets": int(row["retry_packets"] or 0),
"retry_rate_pct": retry_rate_pct_for_type,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct_for_type,
"avg_attempts": avg_attempts_for_type,
"attempts_1": int(row["attempts_1"] or 0),
"attempts_2": int(row["attempts_2"] or 0),
"attempts_3": int(row["attempts_3"] or 0),
"attempts_4_plus": int(row["attempts_4_plus"] or 0),
"attempts_3_plus": attempts_3_plus,
"attempts_3_plus_pct": attempts_3_plus_pct_for_type,
"max_attempts": int(row["max_attempts"] or 0),
"failed_transmissions": int(row["failed_transmissions"] or 0),
"severe_contention_count": int(row["severe_contention_count"] or 0),
}
)
total_entry = packet_type_totals.setdefault(
packet_type,
{
"packet_type": packet_type,
"packet_type_label": _packet_type_name(packet_type),
"transmissions": 0,
"retry_packets": 0,
},
)
total_entry["transmissions"] += transmissions
total_entry["retry_packets"] += int(row["retry_packets"] or 0)
packet_types = []
for pkt_type in sorted(
packet_type_totals.keys(),
key=lambda key: packet_type_totals[key]["transmissions"],
reverse=True,
):
entry = packet_type_totals[pkt_type]
transmissions = int(entry["transmissions"])
retry_rate_pct_for_type = None
if transmissions > 0:
retry_rate_pct_for_type = (int(entry["retry_packets"]) * 100.0) / transmissions
packet_types.append(
{
"packet_type": int(entry["packet_type"]),
"packet_type_label": str(entry["packet_type_label"]),
"transmissions": transmissions,
"retry_packets": int(entry["retry_packets"]),
"retry_rate_pct": retry_rate_pct_for_type,
}
)
return {
"start_time": int(start_timestamp),
"end_time": int(end_timestamp),
"bucket_seconds": bucket_seconds,
"summary": summary,
"buckets": buckets,
"packet_types": packet_types,
"packet_type_buckets": packet_type_buckets,
}
except Exception as e:
logger.error(f"Failed to get LBT diagnostics: {e}")
return {
"start_time": int(start_timestamp),
"end_time": int(end_timestamp),
"bucket_seconds": max(60, min(int(bucket_seconds), 3600)),
"summary": {
"total_transmissions": 0,
"total_attempts": 0,
"first_attempt_success": 0,
"retry_packets": 0,
"retry_rate_pct": None,
"first_attempt_success_rate_pct": None,
"avg_attempts": None,
"median_attempts": None,
"p95_attempts": None,
"max_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"attempts_3_plus": 0,
"attempts_3_plus_pct": None,
"attempts_4_plus_pct": None,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"severe_contention_pct": None,
"severe_attempt_threshold": max(2, int(severe_attempt_threshold)),
"has_lbt_data": False,
"worst_bucket": None,
},
"buckets": [],
"packet_types": [],
"packet_type_buckets": [],
}
def get_packet_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -380,6 +380,20 @@ class StorageCollector:
bucket_seconds=bucket_seconds,
)
def get_lbt_diagnostics(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 300,
severe_attempt_threshold: int = 4,
) -> dict:
return self.sqlite_handler.get_lbt_diagnostics(
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
bucket_seconds=bucket_seconds,
severe_attempt_threshold=severe_attempt_threshold,
)
def get_packet_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_packet_stats(hours)
+290
View File
@@ -447,6 +447,131 @@ class APIEndpoints:
values = [v if v is not None else 0 for v in data_points]
return [[timestamps_ms[i], values[i]] for i in range(min(len(values), len(timestamps_ms)))]
@staticmethod
def _pearson_correlation(left: list[float], right: list[float]) -> Optional[float]:
if len(left) != len(right) or len(left) < 5:
return None
mean_left = sum(left) / len(left)
mean_right = sum(right) / len(right)
numerator = 0.0
left_variance = 0.0
right_variance = 0.0
for i in range(len(left)):
dx = left[i] - mean_left
dy = right[i] - mean_right
numerator += dx * dy
left_variance += dx * dx
right_variance += dy * dy
denominator = (left_variance * right_variance) ** 0.5
if denominator <= 0:
return None
return numerator / denominator
@staticmethod
def _auto_bucket_seconds(range_seconds: int) -> int:
if range_seconds <= 0:
return 60
target = max(60, int(range_seconds / 120))
rounded = ((target + 59) // 60) * 60
return max(60, min(rounded, 3600))
def _build_rrd_bucket_metrics(self, rrd_data: Optional[dict], bucket_seconds: int) -> dict:
if not rrd_data:
return {}
timestamps = rrd_data.get("timestamps") or []
metrics = rrd_data.get("metrics") or {}
if not isinstance(timestamps, list) or not isinstance(metrics, dict):
return {}
def _counter_delta(values: list) -> list[float]:
output = []
previous = None
for item in values:
if item is None:
output.append(0.0)
elif previous is None:
output.append(0.0)
previous = item
else:
output.append(float(max(0, item - previous)))
previous = item
return output
rx_values = _counter_delta(metrics.get("rx_count", []))
tx_values = _counter_delta(metrics.get("tx_count", []))
drop_values = _counter_delta(metrics.get("drop_count", []))
rssi_values = metrics.get("avg_rssi", []) or []
snr_values = metrics.get("avg_snr", []) or []
bucket_map: dict = {}
max_len = len(timestamps)
for i in range(max_len):
ts = int(timestamps[i])
bucket_ts = int(ts / bucket_seconds) * bucket_seconds
bucket = bucket_map.setdefault(
bucket_ts,
{
"rx_count": 0.0,
"tx_count": 0.0,
"drop_count": 0.0,
"avg_rssi_sum": 0.0,
"avg_rssi_samples": 0,
"avg_snr_sum": 0.0,
"avg_snr_samples": 0,
},
)
if i < len(rx_values):
bucket["rx_count"] += float(rx_values[i] or 0.0)
if i < len(tx_values):
bucket["tx_count"] += float(tx_values[i] or 0.0)
if i < len(drop_values):
bucket["drop_count"] += float(drop_values[i] or 0.0)
if i < len(rssi_values) and rssi_values[i] is not None:
bucket["avg_rssi_sum"] += float(rssi_values[i])
bucket["avg_rssi_samples"] += 1
if i < len(snr_values) and snr_values[i] is not None:
bucket["avg_snr_sum"] += float(snr_values[i])
bucket["avg_snr_samples"] += 1
finalized: dict = {}
for bucket_ts, raw in bucket_map.items():
rx_count = float(raw["rx_count"])
tx_count = float(raw["tx_count"])
drop_count = float(raw["drop_count"])
tx_drop_total = tx_count + drop_count
avg_rssi = None
if raw["avg_rssi_samples"] > 0:
avg_rssi = raw["avg_rssi_sum"] / raw["avg_rssi_samples"]
avg_snr = None
if raw["avg_snr_samples"] > 0:
avg_snr = raw["avg_snr_sum"] / raw["avg_snr_samples"]
packet_loss_rate_pct = None
if tx_drop_total > 0:
packet_loss_rate_pct = (drop_count * 100.0) / tx_drop_total
finalized[bucket_ts] = {
"rx_count": int(round(rx_count)),
"tx_count": int(round(tx_count)),
"drop_count": int(round(drop_count)),
"traffic_volume": int(round(rx_count + tx_count)),
"packet_loss_rate_pct": packet_loss_rate_pct,
"avg_rssi": avg_rssi,
"avg_snr": avg_snr,
}
return finalized
def _setup_status_from_config(self, config: dict) -> tuple[bool, dict]:
"""Return whether first-run setup should still be available."""
node_name = config.get("repeater", {}).get("node_name", "")
@@ -3268,6 +3393,171 @@ class APIEndpoints:
logger.error(f"Error getting metrics graph data: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def lbt_diagnostics(
self,
hours=24,
start_timestamp=None,
end_timestamp=None,
bucket_seconds=None,
severe_attempt_threshold=4,
):
"""Return aggregated LBT diagnostics aligned to RF-health buckets."""
try:
max_hours = 168
hours_int = max(1, min(int(hours), max_hours))
now = time.time()
if start_timestamp is not None or end_timestamp is not None:
if start_timestamp is None and end_timestamp is not None:
end_ts = float(end_timestamp)
start_ts = end_ts - (hours_int * 3600)
elif end_timestamp is None and start_timestamp is not None:
start_ts = float(start_timestamp)
end_ts = now
else:
start_ts = float(start_timestamp)
end_ts = float(end_timestamp)
else:
start_ts, end_ts = self._get_time_range(hours_int)
start_ts = float(start_ts)
end_ts = float(end_ts)
if end_ts < start_ts:
start_ts, end_ts = end_ts, start_ts
range_seconds = int(end_ts - start_ts)
if range_seconds <= 0:
return self._error("Invalid time range")
if range_seconds > max_hours * 3600:
return self._error(f"Time range too large. Max range is {max_hours} hours")
if bucket_seconds is None:
bucket_s = self._auto_bucket_seconds(range_seconds)
else:
bucket_s = max(60, min(int(bucket_seconds), 3600))
severe_threshold = max(2, min(int(severe_attempt_threshold), 16))
storage = self._get_storage()
lbt = storage.get_lbt_diagnostics(
start_timestamp=start_ts,
end_timestamp=end_ts,
bucket_seconds=bucket_s,
severe_attempt_threshold=severe_threshold,
)
rrd_data = storage.get_rrd_data(
start_time=int(start_ts),
end_time=int(end_ts),
resolution="average",
)
rf_by_bucket = self._build_rrd_bucket_metrics(rrd_data, bucket_s)
merged_buckets = []
for bucket in lbt.get("buckets", []):
bucket_ts = int(bucket.get("timestamp", 0))
rf = rf_by_bucket.get(bucket_ts, {})
merged_buckets.append(
{
**bucket,
"rf": {
"avg_rssi": rf.get("avg_rssi"),
"avg_snr": rf.get("avg_snr"),
"packet_loss_rate_pct": rf.get("packet_loss_rate_pct"),
"traffic_volume": rf.get("traffic_volume", 0),
"rx_count": rf.get("rx_count", 0),
"tx_count": rf.get("tx_count", 0),
"drop_count": rf.get("drop_count", 0),
},
}
)
merged_packet_type_buckets = []
for bucket in lbt.get("packet_type_buckets", []):
bucket_ts = int(bucket.get("timestamp", 0))
rf = rf_by_bucket.get(bucket_ts, {})
merged_packet_type_buckets.append(
{
**bucket,
"rf": {
"avg_rssi": rf.get("avg_rssi"),
"avg_snr": rf.get("avg_snr"),
"packet_loss_rate_pct": rf.get("packet_loss_rate_pct"),
"traffic_volume": rf.get("traffic_volume", 0),
"rx_count": rf.get("rx_count", 0),
"tx_count": rf.get("tx_count", 0),
"drop_count": rf.get("drop_count", 0),
},
}
)
def _build_correlation(metric_getter):
left = []
right = []
for item in merged_buckets:
retry_rate = item.get("retry_rate_pct")
metric_value = metric_getter(item)
if retry_rate is None or metric_value is None:
continue
left.append(float(retry_rate))
right.append(float(metric_value))
coeff = self._pearson_correlation(left, right)
if coeff is None:
return {
"coefficient": None,
"sample_count": len(left),
"note": "Insufficient or non-varying samples",
}
return {
"coefficient": coeff,
"sample_count": len(left),
"note": None,
}
correlations = {
"retry_rate_vs_avg_snr": _build_correlation(
lambda item: item.get("rf", {}).get("avg_snr")
),
"retry_rate_vs_avg_rssi": _build_correlation(
lambda item: item.get("rf", {}).get("avg_rssi")
),
"retry_rate_vs_packet_loss_rate": _build_correlation(
lambda item: item.get("rf", {}).get("packet_loss_rate_pct")
),
"retry_rate_vs_traffic_volume": _build_correlation(
lambda item: item.get("rf", {}).get("traffic_volume")
),
}
diagnostics = {
"start_time": int(start_ts),
"end_time": int(end_ts),
"bucket_seconds": int(bucket_s),
"severe_attempt_threshold": severe_threshold,
"summary": lbt.get("summary", {}),
"buckets": merged_buckets,
"packet_types": lbt.get("packet_types", []),
"packet_type_buckets": merged_packet_type_buckets,
"correlations": correlations,
"limitations": [
"LBT attempts are derived from stored per-packet retry counts (lbt_attempts + 1).",
"Per-attempt RSSI/SNR and channel frequency are not recorded for each LBT attempt.",
"Airtime utilisation is not available in the current RRD metric set for direct alignment.",
],
}
return self._success(diagnostics)
except ValueError as e:
return self._error(f"Invalid parameter format: {e}")
except Exception as e:
logger.error(f"Error getting LBT diagnostics: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
+435
View File
@@ -1132,6 +1132,66 @@ paths:
items:
type: number
/lbt_diagnostics:
get:
tags: [Charts]
summary: Get LBT diagnostics aligned with RF metrics
description: |
Returns aggregated Listen Before Talk (LBT) diagnostics for transmission-path
packets, aligned to RF health buckets for correlation analysis.
Notes:
- LBT total attempts are derived as `lbt_attempts + 1` from stored packet metadata.
- Buckets are bounded and aggregated server-side for efficient dashboard refresh.
parameters:
- name: hours
in: query
schema:
type: integer
minimum: 1
maximum: 168
default: 24
description: Fallback range in hours when explicit timestamps are not provided.
- name: start_timestamp
in: query
schema:
type: number
format: float
description: Inclusive start timestamp (Unix epoch seconds).
- name: end_timestamp
in: query
schema:
type: number
format: float
description: Inclusive end timestamp (Unix epoch seconds).
- name: bucket_seconds
in: query
schema:
type: integer
minimum: 60
maximum: 3600
description: Bucket width in seconds. If omitted, server auto-selects based on range.
- name: severe_attempt_threshold
in: query
schema:
type: integer
minimum: 2
maximum: 16
default: 4
description: Attempt threshold used to classify severe contention events.
responses:
'200':
description: LBT diagnostics response
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/LbtDiagnosticsResponse'
/noise_floor_history:
get:
tags: [Noise Floor]
@@ -4158,6 +4218,381 @@ components:
type: string
description: Error message
LbtDiagnosticsResponse:
type: object
required: [start_time, end_time, bucket_seconds, severe_attempt_threshold, summary, buckets, packet_types, packet_type_buckets, correlations, limitations]
properties:
start_time:
type: integer
description: Selected range start timestamp (Unix epoch seconds)
end_time:
type: integer
description: Selected range end timestamp (Unix epoch seconds)
bucket_seconds:
type: integer
minimum: 60
maximum: 3600
description: Aggregation interval in seconds
severe_attempt_threshold:
type: integer
minimum: 2
maximum: 16
description: Attempt count threshold used for severe contention classification
summary:
$ref: '#/components/schemas/LbtSummary'
buckets:
type: array
items:
$ref: '#/components/schemas/LbtBucket'
packet_types:
type: array
description: Packet types present in the selected range, sorted by transmission volume.
items:
$ref: '#/components/schemas/LbtPacketTypeSummary'
packet_type_buckets:
type: array
description: Sparse per-packet-type bucket diagnostics (only combinations with transmissions).
items:
$ref: '#/components/schemas/LbtPacketTypeBucket'
correlations:
$ref: '#/components/schemas/LbtCorrelationSet'
limitations:
type: array
items:
type: string
LbtPacketTypeSummary:
type: object
required: [packet_type, packet_type_label, transmissions, retry_packets]
properties:
packet_type:
type: integer
description: Raw packet type code from packet records.
packet_type_label:
type: string
description: Human-readable packet type label.
transmissions:
type: integer
minimum: 0
retry_packets:
type: integer
minimum: 0
retry_rate_pct:
type: number
format: float
nullable: true
LbtPacketTypeBucket:
type: object
required: [timestamp, packet_type, packet_type_label, transmissions, total_attempts, first_attempt_success, retry_packets, attempts_1, attempts_2, attempts_3, attempts_4_plus, attempts_3_plus, max_attempts, failed_transmissions, severe_contention_count, rf]
properties:
timestamp:
type: integer
description: Bucket timestamp (Unix epoch seconds)
packet_type:
type: integer
description: Raw packet type code from packet records.
packet_type_label:
type: string
description: Human-readable packet type label.
transmissions:
type: integer
minimum: 0
total_attempts:
type: integer
minimum: 0
first_attempt_success:
type: integer
minimum: 0
retry_packets:
type: integer
minimum: 0
retry_rate_pct:
type: number
format: float
nullable: true
first_attempt_success_rate_pct:
type: number
format: float
nullable: true
avg_attempts:
type: number
format: float
nullable: true
attempts_1:
type: integer
minimum: 0
attempts_2:
type: integer
minimum: 0
attempts_3:
type: integer
minimum: 0
attempts_4_plus:
type: integer
minimum: 0
attempts_3_plus:
type: integer
minimum: 0
attempts_3_plus_pct:
type: number
format: float
nullable: true
max_attempts:
type: integer
minimum: 0
failed_transmissions:
type: integer
minimum: 0
severe_contention_count:
type: integer
minimum: 0
rf:
$ref: '#/components/schemas/LbtRfBucket'
LbtSummary:
type: object
required: [total_transmissions, total_attempts, first_attempt_success, retry_packets, max_attempts, attempts_1, attempts_2, attempts_3, attempts_4_plus, attempts_3_plus, failed_transmissions, busy_channel_events, severe_contention_count, severe_attempt_threshold, has_lbt_data]
properties:
total_transmissions:
type: integer
minimum: 0
total_attempts:
type: integer
minimum: 0
first_attempt_success:
type: integer
minimum: 0
retry_packets:
type: integer
minimum: 0
retry_rate_pct:
type: number
format: float
nullable: true
first_attempt_success_rate_pct:
type: number
format: float
nullable: true
avg_attempts:
type: number
format: float
nullable: true
median_attempts:
type: number
format: float
nullable: true
p95_attempts:
type: number
format: float
nullable: true
max_attempts:
type: integer
minimum: 0
attempts_1:
type: integer
minimum: 0
attempts_2:
type: integer
minimum: 0
attempts_3:
type: integer
minimum: 0
attempts_4_plus:
type: integer
minimum: 0
attempts_3_plus:
type: integer
minimum: 0
attempts_3_plus_pct:
type: number
format: float
nullable: true
attempts_4_plus_pct:
type: number
format: float
nullable: true
failed_transmissions:
type: integer
minimum: 0
busy_channel_events:
type: integer
minimum: 0
severe_contention_count:
type: integer
minimum: 0
severe_contention_pct:
type: number
format: float
nullable: true
severe_attempt_threshold:
type: integer
minimum: 2
maximum: 16
has_lbt_data:
type: boolean
worst_bucket:
$ref: '#/components/schemas/LbtWorstBucket'
nullable: true
LbtWorstBucket:
type: object
required: [timestamp, retry_rate_pct, attempts_3_plus_pct, max_attempts, transmissions]
properties:
timestamp:
type: integer
retry_rate_pct:
type: number
format: float
attempts_3_plus_pct:
type: number
format: float
max_attempts:
type: integer
minimum: 0
transmissions:
type: integer
minimum: 0
LbtBucket:
type: object
required: [timestamp, transmissions, total_attempts, first_attempt_success, retry_packets, max_attempts, attempts_1, attempts_2, attempts_3, attempts_4_plus, attempts_3_plus, failed_transmissions, busy_channel_events, severe_contention_count, rf]
properties:
timestamp:
type: integer
description: Bucket timestamp (Unix epoch seconds)
transmissions:
type: integer
minimum: 0
total_attempts:
type: integer
minimum: 0
first_attempt_success:
type: integer
minimum: 0
retry_packets:
type: integer
minimum: 0
retry_rate_pct:
type: number
format: float
nullable: true
first_attempt_success_rate_pct:
type: number
format: float
nullable: true
avg_attempts:
type: number
format: float
nullable: true
median_attempts:
type: number
format: float
nullable: true
p95_attempts:
type: number
format: float
nullable: true
max_attempts:
type: integer
minimum: 0
attempts_1:
type: integer
minimum: 0
attempts_2:
type: integer
minimum: 0
attempts_3:
type: integer
minimum: 0
attempts_4_plus:
type: integer
minimum: 0
attempts_3_plus:
type: integer
minimum: 0
attempts_3_plus_pct:
type: number
format: float
nullable: true
attempts_4_plus_pct:
type: number
format: float
nullable: true
failed_transmissions:
type: integer
minimum: 0
busy_channel_events:
type: integer
minimum: 0
severe_contention_count:
type: integer
minimum: 0
severe_contention_pct:
type: number
format: float
nullable: true
rf:
$ref: '#/components/schemas/LbtRfBucket'
LbtRfBucket:
type: object
required: [traffic_volume, rx_count, tx_count, drop_count]
properties:
avg_rssi:
type: number
format: float
nullable: true
avg_snr:
type: number
format: float
nullable: true
packet_loss_rate_pct:
type: number
format: float
nullable: true
traffic_volume:
type: integer
minimum: 0
rx_count:
type: integer
minimum: 0
tx_count:
type: integer
minimum: 0
drop_count:
type: integer
minimum: 0
LbtCorrelationSet:
type: object
required: [retry_rate_vs_avg_snr, retry_rate_vs_avg_rssi, retry_rate_vs_packet_loss_rate, retry_rate_vs_traffic_volume]
properties:
retry_rate_vs_avg_snr:
$ref: '#/components/schemas/LbtCorrelationValue'
retry_rate_vs_avg_rssi:
$ref: '#/components/schemas/LbtCorrelationValue'
retry_rate_vs_packet_loss_rate:
$ref: '#/components/schemas/LbtCorrelationValue'
retry_rate_vs_traffic_volume:
$ref: '#/components/schemas/LbtCorrelationValue'
LbtCorrelationValue:
type: object
required: [coefficient, sample_count]
properties:
coefficient:
type: number
format: float
nullable: true
description: Pearson coefficient in range [-1, 1] when enough samples exist
sample_count:
type: integer
minimum: 0
note:
type: string
nullable: true
RoomMessage:
type: object
required: [id, author_pubkey, post_timestamp, message_text, txt_type]
+172
View File
@@ -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()
+163
View File
@@ -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"] == []