refactor: Replace auto-clustering with fixed region anchors

- Regions are now user-defined anchor points (name + lat/lon)
- Nodes assigned to nearest region, no distance limits
- Removed auto-naming and region_labels/infra_overrides
- Added Idaho region defaults in TUI
- Simpler, deterministic, user-controlled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-04 17:35:28 +00:00
commit c3f1347b4b
4 changed files with 152 additions and 201 deletions

View file

@ -1021,7 +1021,7 @@ class Configurator:
while True:
self._clear()
console.print("[bold]Mesh Intelligence Settings[/bold]\n")
console.print("[dim]Geographic clustering and health scoring for mesh analysis.[/dim]\n")
console.print("[dim]Region-based health scoring for mesh analysis.[/dim]\n")
mi = self.config.mesh_intelligence
@ -1031,13 +1031,11 @@ class Configurator:
table.add_column("Value", style="green")
table.add_row("1", "Enabled", self._status_icon(mi.enabled))
table.add_row("2", "Region Radius (miles)", str(mi.region_radius_miles))
table.add_row("2", "Regions", f"{len(mi.regions)} defined" if mi.regions else "[dim]none[/dim]")
table.add_row("3", "Locality Radius (miles)", str(mi.locality_radius_miles))
table.add_row("4", "Offline Threshold (hours)", str(mi.offline_threshold_hours))
table.add_row("5", "Packet Threshold (24h)", str(mi.packet_threshold))
table.add_row("6", "Battery Warning (%)", str(mi.battery_warning_percent))
table.add_row("7", "Region Labels", f"{len(mi.region_labels)} custom" if mi.region_labels else "[dim]auto[/dim]")
table.add_row("8", "Infra Overrides", f"{len(mi.infra_overrides)} nodes" if mi.infra_overrides else "[dim]none[/dim]")
table.add_row("0", "Back", "")
console.print(table)
@ -1051,10 +1049,7 @@ class Configurator:
mi.enabled = not mi.enabled
self.modified = True
elif choice == 2:
value = float(Prompt.ask("Region radius (miles)", default=str(mi.region_radius_miles)))
if value != mi.region_radius_miles:
mi.region_radius_miles = value
self.modified = True
self._edit_regions()
elif choice == 3:
value = float(Prompt.ask("Locality radius (miles)", default=str(mi.locality_radius_miles)))
if value != mi.locality_radius_miles:
@ -1075,35 +1070,36 @@ class Configurator:
if value != mi.battery_warning_percent:
mi.battery_warning_percent = value
self.modified = True
elif choice == 7:
self._edit_region_labels()
elif choice == 8:
self._edit_infra_overrides()
def _edit_region_labels(self) -> None:
"""Edit region label overrides."""
def _edit_regions(self) -> None:
"""Edit region anchor points."""
from ..config import RegionAnchor
while True:
self._clear()
console.print("[bold]Region Labels[/bold]\n")
console.print("[dim]Override auto-generated region names with custom labels.[/dim]\n")
console.print("[bold]Region Anchors[/bold]\n")
console.print("[dim]Define region center points. Nodes are assigned to nearest region.[/dim]\n")
labels = self.config.mesh_intelligence.region_labels
regions = self.config.mesh_intelligence.regions
if labels:
if regions:
table = Table(box=box.ROUNDED)
table.add_column("#", style="cyan", width=3)
table.add_column("Auto Name", style="white")
table.add_column("Custom Label", style="green")
table.add_column("Name", style="white")
table.add_column("Latitude", style="green")
table.add_column("Longitude", style="green")
for i, (auto, custom) in enumerate(labels.items(), 1):
table.add_row(str(i), auto, custom)
for i, r in enumerate(regions, 1):
table.add_row(str(i), r.name, f"{r.lat:.4f}", f"{r.lon:.4f}")
console.print(table)
else:
console.print("[dim]No custom labels configured.[/dim]")
console.print("[dim]No regions defined.[/dim]")
console.print()
console.print("[cyan]1.[/cyan] Add label")
console.print("[cyan]2.[/cyan] Remove label")
console.print("[cyan]1.[/cyan] Add region")
console.print("[cyan]2.[/cyan] Edit region")
console.print("[cyan]3.[/cyan] Remove region")
console.print("[cyan]4.[/cyan] Load Idaho defaults")
console.print("[cyan]0.[/cyan] Back")
console.print()
@ -1112,83 +1108,57 @@ class Configurator:
if choice == 0:
return
elif choice == 1:
auto_name = Prompt.ask("Auto-generated name to override (e.g., 'Twin Falls')")
if auto_name:
custom_label = Prompt.ask("Custom label")
if custom_label:
self.config.mesh_intelligence.region_labels[auto_name] = custom_label
name = Prompt.ask("Region name")
if name:
lat = float(Prompt.ask("Center latitude", default="0.0"))
lon = float(Prompt.ask("Center longitude", default="0.0"))
self.config.mesh_intelligence.regions.append(
RegionAnchor(name=name, lat=lat, lon=lon)
)
self.modified = True
console.print(f"[green]Added: '{auto_name}' -> '{custom_label}'[/green]")
console.print(f"[green]Added: {name} @ {lat}, {lon}[/green]")
input("Press Enter to continue...")
elif choice == 2:
if not labels:
console.print("[yellow]No labels to remove.[/yellow]")
input("\nPress Enter to continue...")
continue
keys = list(labels.keys())
console.print()
for i, k in enumerate(keys, 1):
console.print(f"[cyan]{i}.[/cyan] {k} -> {labels[k]}")
console.print("[cyan]0.[/cyan] Cancel")
console.print()
idx = IntPrompt.ask("Select label to remove", default=0)
if 1 <= idx <= len(keys):
key = keys[idx - 1]
del self.config.mesh_intelligence.region_labels[key]
self.modified = True
console.print(f"[green]Removed '{key}'[/green]")
input("Press Enter to continue...")
def _edit_infra_overrides(self) -> None:
"""Edit infrastructure override node IDs."""
while True:
self._clear()
console.print("[bold]Infrastructure Overrides[/bold]\n")
console.print("[dim]Node IDs to exclude from infrastructure classification.[/dim]\n")
overrides = self.config.mesh_intelligence.infra_overrides
if overrides:
for i, node_id in enumerate(overrides, 1):
console.print(f"[cyan]{i}.[/cyan] {node_id}")
else:
console.print("[dim]No overrides configured.[/dim]")
console.print()
console.print("[cyan]1.[/cyan] Add node ID")
console.print("[cyan]2.[/cyan] Remove node ID")
console.print("[cyan]0.[/cyan] Back")
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
node_id = Prompt.ask("Node ID to exclude from infra (e.g., !abc12345)")
if node_id and node_id not in overrides:
self.config.mesh_intelligence.infra_overrides.append(node_id)
self.modified = True
console.print(f"[green]Added: {node_id}[/green]")
input("Press Enter to continue...")
elif node_id in overrides:
console.print("[yellow]Node ID already in list.[/yellow]")
input("\nPress Enter to continue...")
elif choice == 2:
if not overrides:
console.print("[yellow]No overrides to remove.[/yellow]")
if not regions:
console.print("[yellow]No regions to edit.[/yellow]")
input("\nPress Enter to continue...")
continue
console.print()
for i, node_id in enumerate(overrides, 1):
console.print(f"[cyan]{i}.[/cyan] {node_id}")
for i, r in enumerate(regions, 1):
console.print(f"[cyan]{i}.[/cyan] {r.name}")
console.print("[cyan]0.[/cyan] Cancel")
console.print()
idx = IntPrompt.ask("Select to remove", default=0)
if 1 <= idx <= len(overrides):
removed = self.config.mesh_intelligence.infra_overrides.pop(idx - 1)
idx = IntPrompt.ask("Select region", default=0)
if 1 <= idx <= len(regions):
r = regions[idx - 1]
r.name = Prompt.ask("Name", default=r.name)
r.lat = float(Prompt.ask("Latitude", default=str(r.lat)))
r.lon = float(Prompt.ask("Longitude", default=str(r.lon)))
self.modified = True
console.print(f"[green]Removed: {removed}[/green]")
elif choice == 3:
if not regions:
console.print("[yellow]No regions to remove.[/yellow]")
input("\nPress Enter to continue...")
continue
console.print()
for i, r in enumerate(regions, 1):
console.print(f"[cyan]{i}.[/cyan] {r.name}")
console.print("[cyan]0.[/cyan] Cancel")
idx = IntPrompt.ask("Select region to remove", default=0)
if 1 <= idx <= len(regions):
removed = self.config.mesh_intelligence.regions.pop(idx - 1)
self.modified = True
console.print(f"[green]Removed: {removed.name}[/green]")
input("Press Enter to continue...")
elif choice == 4:
# Load Idaho region defaults
self.config.mesh_intelligence.regions = [
RegionAnchor(name="North Idaho", lat=47.5, lon=-116.8),
RegionAnchor(name="Southwestern Idaho", lat=43.6, lon=-116.2),
RegionAnchor(name="South Central Idaho", lat=42.5, lon=-114.5),
RegionAnchor(name="Eastern Idaho", lat=43.5, lon=-112.0),
]
self.modified = True
console.print("[green]Loaded 4 Idaho region defaults.[/green]")
input("Press Enter to continue...")
def _setup_wizard(self) -> None:

View file

@ -176,18 +176,25 @@ class MeshSourceConfig:
enabled: bool = True
@dataclass
class RegionAnchor:
"""A fixed region anchor point."""
name: str = ""
lat: float = 0.0
lon: float = 0.0
@dataclass
class MeshIntelligenceConfig:
"""Mesh intelligence and health scoring settings."""
enabled: bool = False
region_radius_miles: float = 40.0 # Radius for region clustering
locality_radius_miles: float = 8.0 # Radius for locality clustering
regions: list[RegionAnchor] = field(default_factory=list) # Fixed region anchors
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
offline_threshold_hours: int = 24 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag
battery_warning_percent: int = 20 # Battery level for warnings
infra_overrides: list[str] = field(default_factory=list) # Node IDs to exclude from infra
region_labels: dict[str, str] = field(default_factory=dict) # {suggested: custom}
@dataclass
@ -251,6 +258,13 @@ def _dict_to_dataclass(cls, data: dict):
if isinstance(item, dict) else item
for item in value
]
# Handle list of RegionAnchor
elif key == "regions" and isinstance(value, list):
kwargs[key] = [
_dict_to_dataclass(RegionAnchor, item)
if isinstance(item, dict) else item
for item in value
]
else:
kwargs[key] = value

View file

@ -217,13 +217,11 @@ class MeshAI:
if mi_cfg.enabled and self.source_manager:
from .mesh_health import MeshHealthEngine
self.health_engine = MeshHealthEngine(
region_radius=mi_cfg.region_radius_miles,
regions=mi_cfg.regions,
locality_radius=mi_cfg.locality_radius_miles,
offline_threshold_hours=mi_cfg.offline_threshold_hours,
packet_threshold=mi_cfg.packet_threshold,
battery_warning_percent=mi_cfg.battery_warning_percent,
infra_overrides=mi_cfg.infra_overrides,
region_labels=mi_cfg.region_labels,
)
# Initial health computation
mesh_health = self.health_engine.compute(self.source_manager)

View file

@ -14,9 +14,7 @@ from typing import Optional
from .geo import (
cluster_by_distance,
suggest_cluster_name,
get_cluster_center,
assign_to_nearest_cluster,
haversine_distance,
)
@ -26,7 +24,6 @@ logger = logging.getLogger(__name__)
INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
# Default thresholds
DEFAULT_REGION_RADIUS_MILES = 40.0
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
DEFAULT_OFFLINE_THRESHOLD_HOURS = 24
DEFAULT_PACKET_THRESHOLD = 500 # Non-text packets per 24h
@ -127,7 +124,6 @@ class LocalityHealth:
"""Health data for a locality (sub-region cluster)."""
name: str
suggested_name: str = ""
center_lat: float = 0.0
center_lon: float = 0.0
node_ids: list[str] = field(default_factory=list)
@ -139,7 +135,6 @@ class RegionHealth:
"""Health data for a region."""
name: str
suggested_name: str = ""
center_lat: float = 0.0
center_lon: float = 0.0
localities: list[LocalityHealth] = field(default_factory=list)
@ -166,37 +161,47 @@ class MeshHealth:
return len(self.regions)
@dataclass
class RegionAnchor:
"""A fixed region anchor point for assignment."""
name: str
lat: float
lon: float
class MeshHealthEngine:
"""Computes mesh health scores from aggregated source data."""
def __init__(
self,
region_radius: float = DEFAULT_REGION_RADIUS_MILES,
regions: Optional[list] = None,
locality_radius: float = DEFAULT_LOCALITY_RADIUS_MILES,
offline_threshold_hours: int = DEFAULT_OFFLINE_THRESHOLD_HOURS,
packet_threshold: int = DEFAULT_PACKET_THRESHOLD,
battery_warning_percent: int = DEFAULT_BATTERY_WARNING_PERCENT,
infra_overrides: Optional[list[str]] = None,
region_labels: Optional[dict[str, str]] = None,
):
"""Initialize health engine.
Args:
region_radius: Miles radius for region clustering
locality_radius: Miles radius for locality clustering
regions: List of region anchors (dicts or RegionAnchor with name, lat, lon)
locality_radius: Miles radius for locality clustering within regions
offline_threshold_hours: Hours before a node is considered offline
packet_threshold: Non-text packets per 24h to flag a node
battery_warning_percent: Battery level for warnings
infra_overrides: Node IDs to exclude from infrastructure
region_labels: Override labels for regions {suggested_name: custom_label}
"""
self.region_radius = region_radius
# Convert region configs to RegionAnchor objects
self.regions: list[RegionAnchor] = []
if regions:
for r in regions:
if hasattr(r, 'name'):
self.regions.append(RegionAnchor(r.name, r.lat, r.lon))
elif isinstance(r, dict):
self.regions.append(RegionAnchor(r['name'], r['lat'], r['lon']))
self.locality_radius = locality_radius
self.offline_threshold_hours = offline_threshold_hours
self.packet_threshold = packet_threshold
self.battery_warning_percent = battery_warning_percent
self.infra_overrides = set(infra_overrides or [])
self.region_labels = dict(region_labels or {})
self._mesh_health: Optional[MeshHealth] = None
@ -205,6 +210,30 @@ class MeshHealthEngine:
"""Get last computed mesh health."""
return self._mesh_health
def _find_nearest_region(self, lat: float, lon: float) -> Optional[str]:
"""Find the nearest region anchor to a GPS point.
Args:
lat: Latitude
lon: Longitude
Returns:
Region name or None if no regions defined
"""
if not self.regions:
return None
nearest = None
min_dist = float("inf")
for region in self.regions:
dist = haversine_distance(lat, lon, region.lat, region.lon)
if dist < min_dist:
min_dist = dist
nearest = region.name
return nearest
def compute(self, source_manager) -> MeshHealth:
"""Compute mesh health from source data.
@ -219,7 +248,6 @@ class MeshHealthEngine:
# Aggregate all nodes from all sources
all_nodes = source_manager.get_all_nodes()
all_edges = source_manager.get_all_edges()
all_telemetry = source_manager.get_all_telemetry()
all_packets = []
@ -252,8 +280,6 @@ class MeshHealthEngine:
# Determine if infrastructure
is_infra = role.upper() in INFRASTRUCTURE_ROLES
if node_id in self.infra_overrides:
is_infra = False
# Get position (handle different API formats)
lat = node.get("latitude") or node.get("lat")
@ -334,78 +360,44 @@ class MeshHealthEngine:
if "TEXT" in str(port_num).upper():
nodes[from_id].text_packet_count_24h += 1
# Cluster infrastructure nodes into regions
infra_nodes = [n for n in nodes.values() if n.is_infrastructure]
infra_dicts = [
{"id": n.node_id, "latitude": n.latitude, "longitude": n.longitude}
for n in infra_nodes
if n.latitude and n.longitude
]
region_clusters = cluster_by_distance(
infra_dicts,
self.region_radius,
lat_key="latitude",
lon_key="longitude",
id_key="id",
# Initialize regions from anchors
region_map: dict[str, RegionHealth] = {}
for anchor in self.regions:
region_map[anchor.name] = RegionHealth(
name=anchor.name,
center_lat=anchor.lat,
center_lon=anchor.lon,
)
# Build regions
regions: list[RegionHealth] = []
for cluster in region_clusters:
suggested = suggest_cluster_name(cluster)
label = self.region_labels.get(suggested, suggested)
center_lat, center_lon = get_cluster_center(cluster)
region = RegionHealth(
name=label,
suggested_name=suggested,
center_lat=center_lat,
center_lon=center_lon,
node_ids=[n["id"] for n in cluster],
)
regions.append(region)
# Mark nodes with their region
for n in cluster:
if n["id"] in nodes:
nodes[n["id"]].region = label
# Assign non-infrastructure nodes to nearest region
# Assign nodes to nearest region
unlocated = []
for node in nodes.values():
if node.region:
continue # Already assigned
if node.latitude and node.longitude:
# Find nearest region
min_dist = float("inf")
nearest_region = None
for region in regions:
dist = haversine_distance(
node.latitude, node.longitude,
region.center_lat, region.center_lon
)
if dist < min_dist:
min_dist = dist
nearest_region = region
if nearest_region:
node.region = nearest_region.name
nearest_region.node_ids.append(node.node_id)
region_name = self._find_nearest_region(node.latitude, node.longitude)
if region_name and region_name in region_map:
node.region = region_name
region_map[region_name].node_ids.append(node.node_id)
else:
unlocated.append(node.node_id)
else:
unlocated.append(node.node_id)
# Create localities within each region
regions = list(region_map.values())
# Create localities within each region (cluster by proximity)
for region in regions:
if not region.node_ids:
continue
region_nodes = [
{"id": nid, "latitude": nodes[nid].latitude, "longitude": nodes[nid].longitude}
for nid in region.node_ids
if nodes[nid].latitude and nodes[nid].longitude
]
if not region_nodes:
continue
locality_clusters = cluster_by_distance(
region_nodes,
self.locality_radius,
@ -414,13 +406,11 @@ class MeshHealthEngine:
id_key="id",
)
for cluster in locality_clusters:
suggested = suggest_cluster_name(cluster)
for i, cluster in enumerate(locality_clusters):
center_lat, center_lon = get_cluster_center(cluster)
locality = LocalityHealth(
name=suggested,
suggested_name=suggested,
name=f"{region.name} L{i+1}",
center_lat=center_lat,
center_lon=center_lon,
node_ids=[n["id"] for n in cluster],
@ -430,7 +420,7 @@ class MeshHealthEngine:
# Mark nodes with their locality
for n in cluster:
if n["id"] in nodes:
nodes[n["id"]].locality = suggested
nodes[n["id"]].locality = locality.name
# Compute scores at each level
self._compute_locality_scores(regions, nodes)
@ -507,12 +497,10 @@ class MeshHealthEngine:
infra_score = 100.0 # No infrastructure = not penalized
# Channel utilization (simplified - based on packet counts)
# Rough estimate: 1000 packets/day across all nodes = ~15% utilization
total_packets = sum(n.packet_count_24h for n in node_list)
# Estimate utilization: packets / (nodes * 500 baseline)
baseline = len(node_list) * 500
if baseline > 0:
util_percent = (total_packets / baseline) * 15 # Scale to percentage
util_percent = (total_packets / baseline) * 15
else:
util_percent = 0
@ -555,9 +543,8 @@ class MeshHealthEngine:
battery_ratio = battery_warnings / nodes_with_battery
power_score = 100.0 * (1 - battery_ratio)
else:
power_score = 100.0 # No battery data = assume OK
power_score = 100.0
# Solar index (placeholder - would need solar data)
solar_index = 100.0
return HealthScore(
@ -574,14 +561,7 @@ class MeshHealthEngine:
)
def get_region(self, name: str) -> Optional[RegionHealth]:
"""Get a region by name.
Args:
name: Region name (case-insensitive)
Returns:
RegionHealth or None
"""
"""Get a region by name."""
if not self._mesh_health:
return None
@ -589,27 +569,16 @@ class MeshHealthEngine:
for region in self._mesh_health.regions:
if region.name.lower() == name_lower:
return region
if region.suggested_name.lower() == name_lower:
return region
return None
def get_node(self, node_id: str) -> Optional[NodeHealth]:
"""Get a node by ID or short name.
Args:
node_id: Node ID or short name
Returns:
NodeHealth or None
"""
"""Get a node by ID or short name."""
if not self._mesh_health:
return None
# Try direct ID lookup
if node_id in self._mesh_health.nodes:
return self._mesh_health.nodes[node_id]
# Try short name match
node_id_lower = node_id.lower()
for node in self._mesh_health.nodes.values():
if node.short_name.lower() == node_id_lower: