diff --git a/meshai/cli/configurator.py b/meshai/cli/configurator.py index 57991e1..4032808 100644 --- a/meshai/cli/configurator.py +++ b/meshai/cli/configurator.py @@ -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,84 +1108,58 @@ 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 - self.modified = True - console.print(f"[green]Added: '{auto_name}' -> '{custom_label}'[/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] + 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]Removed '{key}'[/green]") + console.print(f"[green]Added: {name} @ {lat}, {lon}[/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: """First-time setup wizard.""" diff --git a/meshai/config.py b/meshai/config.py index 58d70c1..18eea12 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -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 diff --git a/meshai/main.py b/meshai/main.py index 74b9120..71629a5 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -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) diff --git a/meshai/mesh_health.py b/meshai/mesh_health.py index 632bcdb..8e438a3 100644 --- a/meshai/mesh_health.py +++ b/meshai/mesh_health.py @@ -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", - ) - - # 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], + # 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, ) - 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: