mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat: Node source overlap for gateway coverage metrics
Replaces broken per-packet gateway sampling with node-level source counting. Each Meshview/MeshMonitor source represents a gateway view of the mesh. If a node is seen by N sources, its packets are reaching N gateways. - Removed _sample_gateway_coverage() (required non-existent API) - Rewrote _enrich_deliverability() to use node.sources count - Per-node: avg_gateways, max_gateways, source_reach, deliverability_score - Mesh-wide: avg 4.16 gateways/node with 7 sources - Fixed edge.timestamp -> edge.last_seen in get_all_edges() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f30cd0a8bf
commit
c756727cad
1 changed files with 84 additions and 93 deletions
|
|
@ -1204,107 +1204,89 @@ class MeshDataStore:
|
||||||
logger.warning(f"Failed to enrich environmental data: {e}")
|
logger.warning(f"Failed to enrich environmental data: {e}")
|
||||||
|
|
||||||
def _enrich_deliverability(self) -> None:
|
def _enrich_deliverability(self) -> None:
|
||||||
"""Enrich with deliverability metrics from Meshview counts.
|
"""Compute deliverability metrics from node source overlap.
|
||||||
|
|
||||||
Computes mesh-wide average gateways per packet from Meshview's
|
Each Meshview/MeshMonitor source represents a gateway's view of the mesh.
|
||||||
total_packets and total_seen counts.
|
If a node is seen by N sources, its packets are reaching N gateways.
|
||||||
|
This uses node visibility as a proxy for packet deliverability.
|
||||||
"""
|
"""
|
||||||
# Get counts from Meshview sources
|
total_sources = len(self._sources)
|
||||||
for name, source in self._sources.items():
|
if total_sources == 0:
|
||||||
if isinstance(source, MeshviewSource):
|
|
||||||
counts = source.counts
|
|
||||||
if counts:
|
|
||||||
total_packets = counts.get("total_packets", 0)
|
|
||||||
total_seen = counts.get("total_seen", 0)
|
|
||||||
|
|
||||||
if total_packets > 0:
|
|
||||||
avg_gateways = total_seen / total_packets
|
|
||||||
self._deliverability = {
|
|
||||||
"avg_gateways": avg_gateways,
|
|
||||||
"total_packets": total_packets,
|
|
||||||
"total_seen": total_seen,
|
|
||||||
"gateway_count": 1, # Count of unique gateways (from is_mqtt_gateway)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Count MQTT gateways
|
|
||||||
gw_count = sum(
|
|
||||||
1 for n in self._nodes.values()
|
|
||||||
if n.is_mqtt_gateway
|
|
||||||
)
|
|
||||||
if gw_count > 0:
|
|
||||||
self._deliverability["gateway_count"] = gw_count
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Deliverability: avg {avg_gateways:.2f} gateways/packet "
|
|
||||||
f"({total_seen}/{total_packets})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sample gateway coverage for infrastructure nodes
|
|
||||||
self._sample_gateway_coverage(source)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _sample_gateway_coverage(self, source: "MeshviewSource") -> None:
|
|
||||||
"""Sample gateway coverage for infrastructure nodes.
|
|
||||||
|
|
||||||
Samples 10-20 recent packets to measure per-node gateway reach.
|
|
||||||
Updates UnifiedNode.avg_gateways and deliverability_score.
|
|
||||||
"""
|
|
||||||
import random
|
|
||||||
|
|
||||||
# Get infrastructure nodes to sample
|
|
||||||
infra_nodes = [
|
|
||||||
n for n in self._nodes.values()
|
|
||||||
if n.role in {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT", "REPEATER"}
|
|
||||||
and n.packets_sent_24h > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
if not infra_nodes:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Sample up to 5 infrastructure nodes
|
# Count Meshview sources specifically (these are the MQTT gateways)
|
||||||
sample_nodes = random.sample(infra_nodes, min(5, len(infra_nodes)))
|
meshview_count = sum(
|
||||||
sampled_count = 0
|
1 for src in self._sources.values()
|
||||||
|
if isinstance(src, MeshviewSource)
|
||||||
|
)
|
||||||
|
|
||||||
# Check if source supports required methods
|
nodes_with_data = 0
|
||||||
if not hasattr(source, "fetch_recent_packets"):
|
total_gateway_sum = 0
|
||||||
logger.debug("Gateway sampling skipped: source lacks fetch_recent_packets")
|
max_gateways_seen = 0
|
||||||
return
|
|
||||||
|
|
||||||
for node in sample_nodes:
|
for node in self._nodes.values():
|
||||||
# Get recent packets from this node
|
if not node.sources:
|
||||||
packets = source.fetch_recent_packets(node.node_num, limit=5)
|
|
||||||
if not packets:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
gateway_counts = []
|
# Count how many sources see this node
|
||||||
unique_gateways = set()
|
source_count = len(node.sources)
|
||||||
|
|
||||||
for pkt in packets[:5]: # Limit to 5 packets per node
|
# Set per-node metrics
|
||||||
pkt_id = pkt.get("id") or pkt.get("packet_id")
|
node.avg_gateways = float(source_count)
|
||||||
if not pkt_id:
|
node.max_gateways = source_count
|
||||||
continue
|
node.source_reach = float(source_count)
|
||||||
|
|
||||||
seen_data = source.fetch_packets_seen(pkt_id)
|
# Deliverability score: % of max possible sources
|
||||||
if seen_data:
|
node.deliverability_score = (source_count / total_sources) * 100
|
||||||
gateway_counts.append(len(seen_data))
|
|
||||||
for gw in seen_data:
|
|
||||||
gw_id = gw.get("node_id")
|
|
||||||
if gw_id:
|
|
||||||
unique_gateways.add(gw_id)
|
|
||||||
sampled_count += 1
|
|
||||||
|
|
||||||
if gateway_counts:
|
nodes_with_data += 1
|
||||||
avg_gw = sum(gateway_counts) / len(gateway_counts)
|
total_gateway_sum += source_count
|
||||||
# Deliverability: % of packets reaching 2+ gateways
|
max_gateways_seen = max(max_gateways_seen, source_count)
|
||||||
multi_gw = sum(1 for c in gateway_counts if c >= 2)
|
|
||||||
deliver_pct = (multi_gw / len(gateway_counts)) * 100
|
|
||||||
|
|
||||||
node.avg_gateways = avg_gw
|
# Compute mesh-wide metrics
|
||||||
node.deliverability_score = deliver_pct
|
if nodes_with_data > 0:
|
||||||
node.max_gateways = max(gateway_counts) if gateway_counts else None
|
mesh_avg = total_gateway_sum / nodes_with_data
|
||||||
|
self._deliverability = {
|
||||||
|
"avg_gateways": mesh_avg,
|
||||||
|
"max_gateways": max_gateways_seen,
|
||||||
|
"total_sources": total_sources,
|
||||||
|
"meshview_sources": meshview_count,
|
||||||
|
"nodes_with_data": nodes_with_data,
|
||||||
|
"source": "node_source_overlap",
|
||||||
|
}
|
||||||
|
|
||||||
if sampled_count > 0:
|
# Distribution: how many nodes reach N+ gateways
|
||||||
logger.debug(f"Gateway sampling: {sampled_count} packets from {len(sample_nodes)} nodes")
|
dist = {}
|
||||||
|
for threshold in range(1, total_sources + 1):
|
||||||
|
count = sum(
|
||||||
|
1 for n in self._nodes.values()
|
||||||
|
if n.avg_gateways is not None and n.avg_gateways >= threshold
|
||||||
|
)
|
||||||
|
dist[f"reaching_{threshold}_plus"] = count
|
||||||
|
|
||||||
|
self._deliverability["distribution"] = dist
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Deliverability: {nodes_with_data} nodes, "
|
||||||
|
f"avg {mesh_avg:.2f} gateways/node, "
|
||||||
|
f"max {max_gateways_seen}/{total_sources} sources"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to single-source ratio if no node overlap data
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
if isinstance(source, MeshviewSource):
|
||||||
|
counts = source.counts
|
||||||
|
if counts:
|
||||||
|
tp = counts.get("total_packets", 0)
|
||||||
|
ts = counts.get("total_seen", 0)
|
||||||
|
if tp > 0:
|
||||||
|
self._deliverability = {
|
||||||
|
"avg_gateways": ts / tp,
|
||||||
|
"total_sources": total_sources,
|
||||||
|
"nodes_with_data": 0,
|
||||||
|
"source": "single_source_fallback",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
def get_coverage_gaps(self) -> list[dict]:
|
def get_coverage_gaps(self) -> list[dict]:
|
||||||
"""Get nodes with poor coverage (low gateway reach).
|
"""Get nodes with poor coverage (low gateway reach).
|
||||||
|
|
@ -1873,9 +1855,18 @@ class MeshDataStore:
|
||||||
"""Get mesh-wide deliverability metrics.
|
"""Get mesh-wide deliverability metrics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with avg_gateways, total_packets, total_seen, gateway_count
|
Dict with avg_gateways, max_gateways, total_sources, nodes_with_data, etc.
|
||||||
"""
|
"""
|
||||||
return self._deliverability.copy()
|
result = self._deliverability.copy()
|
||||||
|
|
||||||
|
# Add computed summary if we have per-node data
|
||||||
|
nodes_with_gw = [n for n in self._nodes.values() if n.avg_gateways is not None]
|
||||||
|
if nodes_with_gw:
|
||||||
|
result["computed_avg"] = sum(n.avg_gateways for n in nodes_with_gw) / len(nodes_with_gw)
|
||||||
|
result["computed_max"] = max(n.max_gateways or 0 for n in nodes_with_gw)
|
||||||
|
result["computed_nodes"] = len(nodes_with_gw)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_node_deliverability(self, node_num: int) -> Optional[dict]:
|
def get_node_deliverability(self, node_num: int) -> Optional[dict]:
|
||||||
"""Get per-node deliverability if available.
|
"""Get per-node deliverability if available.
|
||||||
|
|
@ -2102,7 +2093,7 @@ class MeshDataStore:
|
||||||
"to_node": edge.to_node,
|
"to_node": edge.to_node,
|
||||||
"snr": edge.snr,
|
"snr": edge.snr,
|
||||||
"rssi": edge.rssi,
|
"rssi": edge.rssi,
|
||||||
"timestamp": edge.timestamp,
|
"timestamp": edge.last_seen,
|
||||||
}
|
}
|
||||||
result.append(edge_dict)
|
result.append(edge_dict)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue