feat: Add route quality analysis and detailed router performance statistics to reports.

This commit is contained in:
eddieoz
2025-11-28 00:51:44 +02:00
parent 992745b50c
commit 800c839bf7
5 changed files with 229 additions and 51 deletions

View File

@@ -79,12 +79,12 @@ class NetworkHealthAnalyzer:
return issues
def check_router_efficiency(self, nodes, test_results=None):
def get_router_stats(self, nodes, test_results=None):
"""
Analyzes router placement and efficiency.
Returns a list of issue strings.
Calculates detailed statistics for each router.
Returns a list of dictionaries.
"""
issues = []
stats = []
routers = []
# 1. Identify Routers
@@ -94,9 +94,10 @@ class NetworkHealthAnalyzer:
is_router = False
if isinstance(role, int):
if role in [2, 3]: # ROUTER, ROUTER_CLIENT (REPEATER is 4, usually dumb)
# 2=ROUTER_CLIENT, 3=ROUTER, 4=REPEATER, 5=TRACKER, 6=SENSOR, 7=TAK, 8=CLIENT_MUTE, 9=ROUTER_LATE
if role in [2, 3, 9]:
is_router = True
elif role in ['ROUTER', 'ROUTER_CLIENT']:
elif role in ['ROUTER', 'ROUTER_CLIENT', 'ROUTER_LATE']:
is_router = True
if is_router:
@@ -108,6 +109,7 @@ class NetworkHealthAnalyzer:
routers.append({
'id': node_id,
'name': get_node_name(node, node_id),
'role': 'ROUTER' if role in [3, 'ROUTER'] else ('ROUTER_LATE' if role in [9, 'ROUTER_LATE'] else 'ROUTER_CLIENT'),
'lat': lat,
'lon': lon,
'metrics': get_val(node, 'deviceMetrics', {})
@@ -115,54 +117,78 @@ class NetworkHealthAnalyzer:
# 2. Analyze Each Router
for r in routers:
# A. Check Density (Redundancy)
# A. Neighbors (2km)
nearby_routers = 0
for other in routers:
if r['id'] == other['id']: continue
dist = haversine(r['lat'], r['lon'], other['lat'], other['lon'])
if dist < 2000: # 2km radius
nearby_routers += 1
total_neighbors = 0
if nearby_routers >= 2:
issues.append(f"Efficiency: Router '{r['name']}' is Redundant. Has {nearby_routers} other routers within 2km. Consolidate?")
for node_id, node in nodes.items():
if node_id == r['id']: continue
pos = get_val(node, 'position', {})
lat = get_val(pos, 'latitude')
lon = get_val(pos, 'longitude')
if lat and lon:
dist = haversine(r['lat'], r['lon'], lat, lon)
if dist < 2000:
total_neighbors += 1
# Check if it's also a router
# (Simplified check, ideally we'd check against the routers list but this is O(N))
user = get_val(node, 'user', {})
role = get_val(user, 'role')
if role in [2, 3, 'ROUTER', 'ROUTER_CLIENT']:
nearby_routers += 1
# B. Check Congestion
ch_util = get_val(r['metrics'], 'channelUtilization', 0)
if ch_util > 20:
issues.append(f"Efficiency: Router '{r['name']}' is Congested (ChUtil {ch_util:.1f}% > 20%).")
# C. Check Relay Efficiency (if we have test results)
# B. Relay Count
relay_count = 0
if test_results:
# Count how many times this router was used as a relay
relay_count = 0
for res in test_results:
route = res.get('route', [])
# route is list of IDs (int or hex string? usually int in packet, but we need to match)
# Let's normalize to check
r_id_num = r['id'].replace('!', '')
try:
r_id_int = int(r_id_num, 16)
except:
r_id_int = 0
if r_id_int in route:
# Normalize route IDs to hex strings for comparison
route_hex = [f"!{n:08x}" if isinstance(n, int) else n for n in route]
if r['id'] in route_hex:
relay_count += 1
# Check for "Ineffective" (High Density of Neighbors but Low Relay Count)
# Count ALL neighbors (clients + routers)
total_neighbors = 0
for node_id, node in nodes.items():
if node_id == r['id']: continue
pos = get_val(node, 'position', {})
lat = get_val(pos, 'latitude')
lon = get_val(pos, 'longitude')
if lat and lon:
dist = haversine(r['lat'], r['lon'], lat, lon)
if dist < 2000:
total_neighbors += 1
if total_neighbors > 5 and relay_count == 0:
issues.append(f"Efficiency: Router '{r['name']}' is Ineffective. Has {total_neighbors} neighbors but relayed 0 packets in tests.")
# C. Channel Util
ch_util = get_val(r['metrics'], 'channelUtilization', 0)
# D. Status / Issues
status_issues = []
if nearby_routers >= 2:
status_issues.append("Redundant")
if ch_util > 20:
status_issues.append("Congested")
if total_neighbors > 5 and relay_count == 0:
status_issues.append("Ineffective")
stats.append({
'id': r['id'],
'name': r['name'],
'role': r['role'],
'neighbors_2km': total_neighbors,
'routers_2km': nearby_routers,
'ch_util': ch_util,
'relay_count': relay_count,
'status': ", ".join(status_issues) if status_issues else "OK"
})
return stats
def check_router_efficiency(self, nodes, test_results=None):
"""
Analyzes router placement and efficiency.
Returns a list of issue strings.
"""
issues = []
stats = self.get_router_stats(nodes, test_results)
for s in stats:
if "Redundant" in s['status']:
issues.append(f"Efficiency: Router '{s['name']}' is Redundant. Has {s['routers_2km']} other routers within 2km. Consolidate?")
if "Congested" in s['status']:
issues.append(f"Efficiency: Router '{s['name']}' is Congested (ChUtil {s['ch_util']:.1f}% > 20%).")
if "Ineffective" in s['status']:
issues.append(f"Efficiency: Router '{s['name']}' is Ineffective. Has {s['neighbors_2km']} neighbors but relayed 0 packets in tests.")
return issues

View File

@@ -286,6 +286,27 @@ class MeshMonitor:
# logger.debug(f"Node info updated: {node}")
pass
def apply_manual_positions(self, nodes):
"""
Applies manual positions from config to nodes.
"""
manual_positions = self.config.get('manual_positions', {})
if not manual_positions:
return
for node_id, pos in manual_positions.items():
if node_id in nodes:
node = nodes[node_id]
# Ensure position dict exists
if 'position' not in node:
node['position'] = {}
# Update position
if 'lat' in pos and 'lon' in pos:
node['position']['latitude'] = pos['lat']
node['position']['longitude'] = pos['lon']
logger.debug(f"Applied manual position to {node_id}: {pos}")
def main_loop(self):
logger.info("Starting monitoring loop...")
while self.running:
@@ -299,6 +320,9 @@ class MeshMonitor:
logger.debug("--- Running Network Analysis ---")
nodes = self.interface.nodes
# Apply Manual Positions
self.apply_manual_positions(nodes)
# Get local node info for distance calculations
my_node = None
if hasattr(self.interface, 'localNode'):
@@ -310,6 +334,7 @@ class MeshMonitor:
# Run Router Efficiency Analysis (using accumulated test results if available)
if self.active_tester:
issues.extend(self.analyzer.check_router_efficiency(nodes, test_results=self.active_tester.test_results))
issues.extend(self.analyzer.check_route_quality(nodes, test_results=self.active_tester.test_results))
else:
issues.extend(self.analyzer.check_router_efficiency(nodes))
@@ -334,7 +359,10 @@ class MeshMonitor:
if hasattr(self.interface, 'localNode'):
local_node = self.interface.localNode
self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node)
# Calculate Router Stats for Report
router_stats = self.analyzer.get_router_stats(nodes, self.active_tester.test_results)
self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats)
# Reset cycle count and results
self.active_tester.completed_cycles = 0
@@ -354,6 +382,19 @@ class MeshMonitor:
# ... exceptions ...
except KeyboardInterrupt:
logger.info("Stopping monitor...")
# Generate partial report if we have nodes (even if no test results yet)
if nodes:
logger.info("Generating partial report before exit...")
local_node = None
if hasattr(self.interface, 'localNode'):
local_node = self.interface.localNode
# Use whatever results we have (could be empty)
results = self.active_tester.test_results if self.active_tester else []
router_stats = self.analyzer.get_router_stats(nodes, results)
self.reporter.generate_report(nodes, results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats)
self.stop()
break
except Exception as e:

View File

@@ -12,7 +12,7 @@ class NetworkReporter:
def __init__(self, report_dir="."):
self.report_dir = report_dir
def generate_report(self, nodes, test_results, analysis_issues, local_node=None):
def generate_report(self, nodes, test_results, analysis_issues, local_node=None, router_stats=None):
"""
Generates a Markdown report based on collected data.
"""
@@ -37,6 +37,10 @@ class NetworkReporter:
# 2. Network Health (Analysis Findings)
self._write_network_health(f, analysis_issues)
# 2.1 Router Performance Table (New)
if router_stats:
self._write_router_performance_table(f, router_stats)
# 3. Route Analysis (New Section)
self._write_route_analysis(f, route_analysis)
@@ -137,6 +141,8 @@ class NetworkReporter:
config.append(issue)
elif "Topology" in issue or "Density" in issue or "hops away" in issue:
topology.append(issue)
elif "Efficiency" in issue or "Route Quality" in issue:
pass # Handled in separate sections
else:
other.append(issue)
@@ -166,12 +172,35 @@ class NetworkReporter:
f.write("### Router Efficiency Analysis\n")
f.write("Analysis of router placement, congestion, and relay performance.\n\n")
for i in efficiency:
# Format: Efficiency: Router 'Name' is Issue. Details.
# Let's make it a bit cleaner
clean_msg = i.replace("Efficiency: ", "")
f.write(f"- {clean_msg}\n")
f.write("\n")
# Separate section for Route Quality
quality = [i for i in analysis_issues if "Route Quality" in i]
if quality:
f.write("### Route Quality Analysis\n")
f.write("Analysis of path efficiency and stability.\n\n")
for i in quality:
clean_msg = i.replace("Route Quality: ", "")
f.write(f"- {clean_msg}\n")
f.write("\n")
def _write_router_performance_table(self, f, router_stats):
f.write("### Router Performance Table\n")
if not router_stats:
f.write("No routers found.\n\n")
return
f.write("| Name | Role | Neighbors (2km) | Routers (2km) | ChUtil | Relayed | Status |\n")
f.write("|---|---|---|---|---|---|---|\n")
for s in router_stats:
f.write(f"| {s['name']} | {s['role']} | {s['neighbors_2km']} | {s['routers_2km']} | {s['ch_util']:.1f}% | {s['relay_count']} | {s['status']} |\n")
f.write("\n")
def _write_traceroute_results(self, f, test_results, nodes, local_node=None):
f.write("## 3. Traceroute Results\n")
if not test_results:

View File

@@ -0,0 +1,47 @@
import unittest
from mesh_monitor.analyzer import NetworkHealthAnalyzer
class TestRouteQuality(unittest.TestCase):
def setUp(self):
self.analyzer = NetworkHealthAnalyzer()
def test_long_path(self):
nodes = {
'!11111111': {'user': {'longName': 'FarNode'}}
}
test_results = [
{'node_id': '!11111111', 'hops_to': 4, 'route': []}
]
issues = self.analyzer.check_route_quality(nodes, test_results)
print("\nLong Path Issues:", issues)
self.assertTrue(any("Long path" in i for i in issues))
def test_favorite_router_usage(self):
nodes = {
'!22222222': {'user': {'longName': 'TargetNode'}},
'!33333333': {'user': {'longName': 'FavRouter'}, 'is_favorite': True}
}
# Route uses FavRouter (ID !33333333 -> 0x33333333 = 858993459)
test_results = [
{'node_id': '!22222222', 'hops_to': 2, 'route': [858993459]}
]
issues = self.analyzer.check_route_quality(nodes, test_results)
print("\nFavorite Router Issues:", issues)
self.assertTrue(any("Favorite Router" in i for i in issues))
def test_weak_signal(self):
nodes = {
'!44444444': {'user': {'longName': 'WeakNode'}}
}
test_results = [
{'node_id': '!44444444', 'snr': -15}
]
issues = self.analyzer.check_route_quality(nodes, test_results)
print("\nWeak Signal Issues:", issues)
self.assertTrue(any("Weak signal" in i for i in issues))
if __name__ == '__main__':
unittest.main()

View File

@@ -99,5 +99,40 @@ class TestRouterEfficiency(unittest.TestCase):
print("\nEffective Issues (Should be empty):", issues)
self.assertFalse(any("GoodRouter" in i for i in issues))
def test_get_router_stats(self):
nodes = {
'!77777777': {
'user': {'id': '!77777777', 'longName': 'StatsRouter', 'role': 'ROUTER'},
'position': {'latitude': 44.0, 'longitude': -78.0},
'deviceMetrics': {'channelUtilization': 25}
}
}
# Add 3 router neighbors
for i in range(3):
nodes[f'!r_neighbor{i}'] = {
'user': {'id': f'!r_neighbor{i}', 'role': 'ROUTER'},
'position': {'latitude': 44.001, 'longitude': -78.001}
}
"""Test that get_router_stats returns correct structure and calculations."""
# Create a mock route where StatsRouter (!77777777) is used as a relay
# 0x77777777 = 2004318071
test_results = [
{'route': [2004318071], 'status': 'success'}
]
stats = self.analyzer.get_router_stats(nodes, test_results)
print("\nRouter Stats:", stats)
self.assertEqual(len(stats), 4) # StatsRouter + 3 neighbors
target = next(s for s in stats if s['id'] == '!77777777')
self.assertEqual(target['neighbors_2km'], 3)
self.assertEqual(target['routers_2km'], 3)
self.assertEqual(target['ch_util'], 25.0)
self.assertEqual(target['relay_count'], 1) # Should be 1 now
self.assertIn('Redundant', target['status'])
self.assertIn('Congested', target['status'])
if __name__ == '__main__':
unittest.main()