feat(mesh): config-driven regions with stale purge and coverage fix

- Extended RegionAnchor with local_name, description, aliases, cities
- Moved region geographic context from hardcoded Python to config.yaml
- Added 7-day stale node purge in _do_refresh (556 → 267 nodes)
- Fixed coverage lookup: str(node_num) → node_num (int key)
- Added bidirectional neighbor lookup for better region assignment
- Dynamic geography building in router from config
- Reporter reads region context from config instead of hardcoded dict

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-05 06:33:38 +00:00
commit de400068dd
6 changed files with 141 additions and 108 deletions

View file

@ -179,11 +179,15 @@ class MeshSourceConfig:
@dataclass @dataclass
class RegionAnchor: class RegionAnchor:
"""A fixed region anchor point.""" """A fixed region anchor point with geographic context."""
name: str = "" name: str = ""
lat: float = 0.0 lat: float = 0.0
lon: float = 0.0 lon: float = 0.0
local_name: str = "" # e.g., "Magic Valley"
description: str = "" # e.g., "Twin Falls, Burley, Jerome along I-84/US-93"
aliases: list[str] = field(default_factory=list) # e.g., ["southern Idaho", "magic valley"]
cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"]
@dataclass @dataclass

View file

@ -250,7 +250,8 @@ class MeshAI:
# Mesh reporter (for LLM prompt injection and commands) # Mesh reporter (for LLM prompt injection and commands)
if self.health_engine and self.data_store: if self.health_engine and self.data_store:
from .mesh_reporter import MeshReporter from .mesh_reporter import MeshReporter
self.mesh_reporter = MeshReporter(self.health_engine, self.data_store) mi_regions = self.config.mesh_intelligence.regions if self.config.mesh_intelligence else []
self.mesh_reporter = MeshReporter(self.health_engine, self.data_store, region_configs=mi_regions)
logger.info("Mesh reporter enabled") logger.info("Mesh reporter enabled")
else: else:
self.mesh_reporter = None self.mesh_reporter = None

View file

@ -30,6 +30,9 @@ from .sources.meshview import MeshviewSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Nodes not heard in this many days are excluded from live model
STALE_NODE_THRESHOLD_DAYS = 7
# Meshtastic role enum mapping (integer -> string) # Meshtastic role enum mapping (integer -> string)
MESHTASTIC_ROLE_MAP = { MESHTASTIC_ROLE_MAP = {
0: "CLIENT", 0: "CLIENT",
@ -351,6 +354,31 @@ class MeshDataStore:
# Source Management # Source Management
# ========================================================================= # =========================================================================
def _purge_stale_nodes(self):
"""Remove nodes not heard from in more than 7 days.
These nodes are stale they inflate counts, create false
coverage gaps, and waste LLM tokens. They still exist in
SQLite historical data, just not in the live model.
"""
import time
cutoff = time.time() - (STALE_NODE_THRESHOLD_DAYS * 86400)
stale_nums = []
for node_num, node in self._nodes.items():
if node.last_heard and node.last_heard < cutoff:
stale_nums.append(node_num)
elif not node.last_heard or node.last_heard == 0:
# No last_heard data at all — also consider stale
stale_nums.append(node_num)
for num in stale_nums:
del self._nodes[num]
if stale_nums:
logger.info(f"Purged {len(stale_nums)} stale nodes (not heard in {STALE_NODE_THRESHOLD_DAYS} days)")
def refresh(self) -> bool: def refresh(self) -> bool:
"""Refresh data from all sources if interval has elapsed. """Refresh data from all sources if interval has elapsed.
@ -403,6 +431,7 @@ class MeshDataStore:
if refreshed > 0: if refreshed > 0:
self._rebuild() self._rebuild()
self._purge_stale_nodes() # Remove nodes not heard in 7+ days
self._store_snapshot() self._store_snapshot()
self._purge_old_data() self._purge_old_data()
self._last_refresh = time.time() self._last_refresh = time.time()

View file

@ -292,37 +292,57 @@ class MeshHealthEngine:
else: else:
unlocated.append(str(node_num)) unlocated.append(str(node_num))
# Build neighbor map from edges # Build BIDIRECTIONAL neighbor map from ALL sources:
# First, create a mapping from numeric node_id to hex id # 1. Each node's own neighbor list (from NeighborInfo packets)
numeric_to_hex: dict[str, str] = {} # 2. REVERSE: if A lists B as neighbor, B also sees A
for node in all_nodes: # 3. Edges from traceroutes and other connections
hex_id = node.get("id") all_neighbor_map: dict[int, set[int]] = {}
num_id = node.get("node_id")
if hex_id and num_id:
numeric_to_hex[str(num_id)] = str(hex_id)
# First: add each node's own neighbor list AND reverse relationships
for node_num, node in nodes.items():
if node.neighbors:
if node_num not in all_neighbor_map:
all_neighbor_map[node_num] = set()
for nb_num in node.neighbors:
all_neighbor_map[node_num].add(nb_num)
# REVERSE: if this node sees nb_num, nb_num also "sees" this node
if nb_num not in all_neighbor_map:
all_neighbor_map[nb_num] = set()
all_neighbor_map[nb_num].add(node_num)
# Second: add from edges (connections from traceroutes, etc.)
if hasattr(data_store, 'edges'):
for edge in data_store.edges:
from_num = edge.from_node
to_num = edge.to_node
if from_num not in all_neighbor_map:
all_neighbor_map[from_num] = set()
if to_num not in all_neighbor_map:
all_neighbor_map[to_num] = set()
all_neighbor_map[from_num].add(to_num)
all_neighbor_map[to_num].add(from_num)
# Also add from raw edges API
all_edges = source_manager.get_all_edges() all_edges = source_manager.get_all_edges()
neighbors: dict[str, set[str]] = {}
for edge in all_edges: for edge in all_edges:
# Get edge endpoints (may be numeric)
from_raw = edge.get("from") or edge.get("from_node") or edge.get("source") from_raw = edge.get("from") or edge.get("from_node") or edge.get("source")
to_raw = edge.get("to") or edge.get("to_node") or edge.get("target") to_raw = edge.get("to") or edge.get("to_node") or edge.get("target")
if not from_raw or not to_raw: if not from_raw or not to_raw:
continue continue
try:
from_num = int(from_raw) if not str(from_raw).startswith("!") else int(str(from_raw)[1:], 16)
to_num = int(to_raw) if not str(to_raw).startswith("!") else int(str(to_raw)[1:], 16)
except (ValueError, TypeError):
continue
if from_num not in all_neighbor_map:
all_neighbor_map[from_num] = set()
if to_num not in all_neighbor_map:
all_neighbor_map[to_num] = set()
all_neighbor_map[from_num].add(to_num)
all_neighbor_map[to_num].add(from_num)
# Convert to hex ID format if numeric # Second pass: Assign unlocated nodes based on BIDIRECTIONAL neighbor map
from_id = numeric_to_hex.get(str(from_raw), str(from_raw)) # This catches nodes that OTHER nodes list as neighbors
to_id = numeric_to_hex.get(str(to_raw), str(to_raw))
if from_id not in neighbors:
neighbors[from_id] = set()
if to_id not in neighbors:
neighbors[to_id] = set()
neighbors[from_id].add(to_id)
neighbors[to_id].add(from_id)
# Second pass: Assign unlocated nodes based on neighbor regions
# Repeat until no more assignments
max_iterations = 10 max_iterations = 10
for _ in range(max_iterations): for _ in range(max_iterations):
newly_assigned = [] newly_assigned = []
@ -337,19 +357,11 @@ class MeshHealthEngine:
if node.region: if node.region:
continue # Already assigned continue # Already assigned
# Count neighbor regions # Use the BIDIRECTIONAL neighbor map
neighbor_ids = neighbors.get(node_id_str, set()) neighbor_nums = all_neighbor_map.get(node_num, set())
region_counts: dict[str, int] = {} region_counts: dict[str, int] = {}
for nid in neighbor_ids: for neighbor_num in neighbor_nums:
# Convert string ID to int for nodes lookup neighbor_node = nodes.get(neighbor_num)
try:
if nid.startswith("!"):
nid_int = int(nid[1:], 16)
else:
nid_int = int(nid)
except (ValueError, AttributeError):
continue
neighbor_node = nodes.get(nid_int)
if neighbor_node and neighbor_node.region: if neighbor_node and neighbor_node.region:
r = neighbor_node.region r = neighbor_node.region
region_counts[r] = region_counts.get(r, 0) + 1 region_counts[r] = region_counts.get(r, 0) + 1

View file

@ -36,21 +36,6 @@ PORTNUM_DISPLAY = {
"ATAK_FORWARDER": "ATAK", "ATAK_FORWARDER": "ATAK",
} }
# Geographic context for region names
REGION_CONTEXT = {
"South Central ID": "Magic Valley (Twin Falls area)",
"South Western ID": "Treasure Valley (Boise metro)",
"South Eastern ID": "Eastern Idaho (Idaho Falls area)",
"Central ID": "Idaho backcountry (Salmon area)",
"Northern ID": "North Idaho (Panhandle)",
"Northern UT": "Northern Utah (Ogden/Logan)",
"Central UT": "Wasatch Front (Salt Lake City)",
"Eastern UT": "Eastern Utah (Vernal/Moab)",
"Western UT": "Western Utah (Tooele)",
"Southern UT": "Southern Utah / Dixie (St. George)",
}
def _clean_portnum(portnum: str) -> str: def _clean_portnum(portnum: str) -> str:
"""Convert raw portnum to display name.""" """Convert raw portnum to display name."""
return PORTNUM_DISPLAY.get(portnum, portnum.replace("_APP", "").replace("_", " ").title()) return PORTNUM_DISPLAY.get(portnum, portnum.replace("_APP", "").replace("_", " ").title())
@ -167,15 +152,28 @@ def _is_valid_temperature(temp_c: Optional[float]) -> bool:
class MeshReporter: class MeshReporter:
"""Builds text blocks for mesh health prompt injection.""" """Builds text blocks for mesh health prompt injection."""
def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore"): def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore", region_configs=None):
"""Initialize reporter. """Initialize reporter.
Args: Args:
health_engine: MeshHealthEngine instance health_engine: MeshHealthEngine instance
data_store: MeshDataStore instance data_store: MeshDataStore instance
region_configs: Optional list of RegionAnchor configs for local names
""" """
self.health_engine = health_engine self.health_engine = health_engine
self.data_store = data_store self.data_store = data_store
self._region_configs = {r.name: r for r in (region_configs or [])}
def _region_context(self, region_name: str) -> str:
"""Get display context for a region from config."""
cfg = self._region_configs.get(region_name)
if not cfg:
return ""
local = getattr(cfg, "local_name", "") or ""
desc = getattr(cfg, "description", "") or ""
if local and desc:
return f"{local} ({desc})"
return local or desc
def build_tier1_summary(self) -> str: def build_tier1_summary(self) -> str:
"""Build compact mesh summary for LLM injection (~500-800 tokens). """Build compact mesh summary for LLM injection (~500-800 tokens).
@ -294,7 +292,9 @@ class MeshReporter:
lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes") lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes")
# Coverage by region - show geographic breakdown # Coverage by region - show geographic breakdown
all_nodes = list(self.data_store.nodes.values()) # MUST use health.nodes (not data_store.nodes) because region is set by health engine
health = self.health_engine.mesh_health
all_nodes = list(health.nodes.values()) if health else []
nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None] nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None]
if nodes_with_gw: if nodes_with_gw:
total_sources = len(self.data_store._sources) total_sources = len(self.data_store._sources)
@ -306,7 +306,7 @@ class MeshReporter:
region_coverage = {} region_coverage = {}
for n in nodes_with_gw: for n in nodes_with_gw:
health_node = health.nodes.get(str(n.node_num)) health_node = health.nodes.get(n.node_num)
region = health_node.region if health_node else "Unlocated" region = health_node.region if health_node else "Unlocated"
if not region: if not region:
region = "Unlocated" region = "Unlocated"
@ -323,7 +323,7 @@ class MeshReporter:
single = sum(1 for c in counts if c <= 1.0) single = sum(1 for c in counts if c <= 1.0)
flag = " !!" if avg < 2.0 else "" flag = " !!" if avg < 2.0 else ""
single_str = f" ({single} 1-gw)" if single > 0 else "" single_str = f" ({single} 1-gw)" if single > 0 else ""
context = REGION_CONTEXT.get(region, "") context = self._region_context(region)
context_str = f" ({context})" if context else "" context_str = f" ({context})" if context else ""
lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}") lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}")
# Show unlocated as informational (no coverage recommendations for these) # Show unlocated as informational (no coverage recommendations for these)
@ -344,7 +344,7 @@ class MeshReporter:
rs = region.score rs = region.score
flag = _tier_flag(rs.tier) flag = _tier_flag(rs.tier)
infra_str = f"{rs.infra_online}/{rs.infra_total} infra" infra_str = f"{rs.infra_online}/{rs.infra_total} infra"
context = REGION_CONTEXT.get(region.name, "") context = self._region_context(region.name)
context_str = f" ({context})" if context else "" context_str = f" ({context})" if context else ""
lines.append( lines.append(
f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}" f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}"
@ -556,7 +556,7 @@ class MeshReporter:
return f"REGION DETAIL: {region_name}\nRegion not found." return f"REGION DETAIL: {region_name}\nRegion not found."
rs = region.score rs = region.score
context = REGION_CONTEXT.get(region.name, "") context = self._region_context(region.name)
context_str = f"{context}" if context else "" context_str = f"{context}" if context else ""
lines = [ lines = [
f"REGION DETAIL: {region.name}{context_str}", f"REGION DETAIL: {region.name}{context_str}",

View file

@ -123,70 +123,42 @@ _CITY_TO_REGION = {
"cedar city": "Southern UT", "cedar city": "Southern UT",
} }
# Mesh awareness instruction for LLM
# Mesh awareness instruction for LLM # Mesh awareness instruction for LLM
_MESH_AWARENESS_PROMPT = """ _MESH_AWARENESS_PROMPT = """
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions): MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
RESPONSE STYLE: RESPONSE STYLE:
- Give DETAILED, data-driven responses with actual numbers - Give DETAILED, data-driven responses with actual numbers
- Talk in GEOGRAPHIC terms reference cities, valleys, corridors people know - Talk in GEOGRAPHIC terms use the local names and cities from REGION GEOGRAPHY below
- Name specific infrastructure nodes by their long name, not just shortnames - Name specific infrastructure nodes by their long name
- Include scores, percentages, node counts, battery levels, gateway counts - Include scores, percentages, node counts, battery levels, gateway counts
- You CAN use 3-5 messages if needed LoRa chunking handles splitting - You CAN use 3-5 messages if needed LoRa chunking handles splitting
- No markdown formatting plain text only - No markdown formatting plain text only
- Be specific and data-driven. Do NOT compress to vague one-liners
REGION GEOGRAPHY use these local names and landmarks:
IDAHO:
- Northern ID: "North Idaho" or "the Panhandle" Coeur d'Alene, Moscow, Lewiston, Sandpoint. Along I-90/US-95.
- Central ID: Idaho backcountry Salmon, Stanley, McCall, Challis. Remote, mountainous, very sparse. Frank Church wilderness.
- South Western ID: "Treasure Valley" Boise, Meridian, Nampa, Caldwell, Eagle, Mountain Home. Idaho's most populated region along I-84. Do NOT call this "southern Idaho."
- South Central ID: "Magic Valley" Twin Falls, Burley, Jerome, Hailey, Gooding, Shoshone, Sun Valley. Snake River Canyon corridor along I-84/US-93. When locals say "southern Idaho" they usually mean this area.
- South Eastern ID: "Eastern Idaho" Idaho Falls, Pocatello, Rexburg, Blackfoot. Upper Snake River Plain, gateway to Yellowstone.
IMPORTANT IDAHO GEOGRAPHY:
- "Southern Idaho" is ambiguous. Do NOT lump Treasure Valley (Boise), Magic Valley (Twin Falls), and Eastern Idaho together. They are distinct areas 100+ miles apart.
- If someone says "southern Idaho" they most likely mean the Magic Valley (South Central ID Twin Falls area).
- If unclear, describe each region separately rather than grouping.
- "Treasure Valley" always means the Boise metro area (South Western ID).
- "Magic Valley" always means the Twin Falls area (South Central ID).
- "Eastern Idaho" means Idaho Falls/Pocatello area (South Eastern ID).
UTAH:
- Northern UT: Ogden, Logan, Brigham City, Cache Valley. Northern Wasatch Front.
- Central UT: "The Wasatch Front" or "Salt Lake" Salt Lake City, Provo, Park City, Orem. About 2/3 of Utah's population lives here.
- Eastern UT: Vernal and the Uinta Basin in the north, Moab and canyon country in the south. Dinosaur NM, Arches, Canyonlands.
- Western UT: Tooele, Wendover. West desert, Bonneville Salt Flats. Very sparse.
- Southern UT: "Dixie" or "Color Country" St. George, Cedar City, Hurricane. Zion, Bryce Canyon country. Fastest growing area in Utah.
ANSWERING COVERAGE QUESTIONS: ANSWERING COVERAGE QUESTIONS:
- Reference the geographic area by local name: "Coverage across the Magic Valley from Burley through Twin Falls to Jerome..." - Reference geographic areas by local name from the region config
- Name infrastructure nodes by long name: "Mount Harrison Router, Indian Springs Router, Two Towers, and North Shoshone relay provide backbone coverage" - Name infrastructure nodes by long name
- Give avg gateways AND explain what it means: "nodes reach an average of 5.7 gateways — excellent redundancy" - Give avg gateways AND explain meaning
- Identify single-gateway nodes as risks: "28 nodes in the Treasure Valley only reach 1 gateway" - Identify single-gateway nodes as risks
- For "where do we need more infrastructure" name the geographic area, explain how many nodes are underserved - For "where do we need infrastructure" name the geographic area, not "Unknown"
- Do NOT recommend infrastructure for "Unlocated" nodes those have no known position - Do NOT recommend infrastructure for Unlocated nodes
ANSWERING NODE QUESTIONS: ANSWERING NODE QUESTIONS:
- Include: hardware model, role, region with local name ("in the Magic Valley near Twin Falls"), battery status, channel utilization, TX airtime, coverage (X/Y gateways), neighbor count, recent traffic with packet breakdown - Include: hardware, role, region with local name, battery, channel util, TX airtime, coverage, neighbors, traffic breakdown
- Name neighbors by long name - Compare metrics to thresholds
- Compare metrics to thresholds: "18.5% channel utilization is moderate — approaching the 20% caution threshold"
- If the node has environmental sensors, include readings
ANSWERING MESH OVERVIEW QUESTIONS: ANSWERING MESH OVERVIEW:
- Lead with composite score and tier - Lead with composite score and 5 pillars
- Break down all 5 pillars with actual scores - Break down by region using local names
- Highlight problem areas by geographic/local name - Highlight problems with specific nodes/areas
- Include top senders, coverage gaps, battery warnings by name
- Include traffic trends and sensor data if available
ABOUT UNLOCATED NODES: ABOUT UNLOCATED NODES:
- About 280 nodes have no GPS position and no neighbor data - Nodes with no GPS and no neighbor data cannot be placed on the map
- These are stale, transient, or from sources that don't track position - Do NOT recommend infrastructure for these areas
- Do NOT recommend infrastructure for "Unknown" or "Unlocated" areas - Focus coverage gaps on located regions only
- When asked about coverage gaps, focus ONLY on located regions
- If asked about unlocated nodes specifically, explain they're nodes without position data that can't be placed on the map IMPORTANT: Do NOT lump different regions together just because they share directional words. Each region is a distinct geographic area.
""" """
@ -631,6 +603,21 @@ class MessageRouter:
# Add mesh awareness instructions # Add mesh awareness instructions
system_prompt += _MESH_AWARENESS_PROMPT system_prompt += _MESH_AWARENESS_PROMPT
# Build region geography from config dynamically
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"]
for region in self.config.mesh_intelligence.regions:
local = getattr(region, "local_name", "") or ""
local_str = f' "{local}"' if local else ""
desc = getattr(region, "description", "") or ""
desc_str = f"{desc}" if desc else ""
aliases = getattr(region, "aliases", []) or []
alias_str = ""
if aliases:
alias_str = f'\n People may call this: {", ".join(aliases)}'
geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}")
system_prompt += "\n".join(geo_lines)
# Update mesh context tracking # Update mesh context tracking
self._update_user_mesh_context( self._update_user_mesh_context(
message.sender_id, message.sender_id,