mirror of
https://github.com/eddieoz/LoRa-Mesh-Analyzer.git
synced 2026-03-28 17:42:59 +01:00
feat: Add route quality analysis and detailed router performance statistics to reports.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
47
tests/test_route_quality.py
Normal file
47
tests/test_route_quality.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user