Team Ai
Apppublic

blackopsrepl/vehicle-routing-python

sourceHugging Faceapache-2.0updated 10mo agoView on Hugging Face
2likes
routing.py623 linesDownload Raw Back to vehicle_routing
1"""2Real-world routing service using OSMnx for road network data.3 4This module provides:5- OSMnxRoutingService: Downloads OSM network, caches locally, computes routes6- DistanceMatrix: Precomputes all pairwise routes with times and geometries7- Haversine fallback when OSMnx is unavailable8"""9 10from __future__ import annotations11 12import logging13import math14from dataclasses import dataclass, field15from pathlib import Path16from typing import TYPE_CHECKING, Optional17 18import polyline19 20if TYPE_CHECKING:21    from .domain import Location22 23logger = logging.getLogger(__name__)24 25# Cache directory for OSM network data26CACHE_DIR = Path(__file__).parent.parent.parent / ".osm_cache"27 28 29@dataclass30class RouteResult:31    """Result from a routing query."""32 33    duration_seconds: int34    distance_meters: int35    geometry: Optional[str] = None  # Encoded polyline36 37 38@dataclass39class DistanceMatrix:40    """41    Precomputed distance/time matrix for all location pairs.42 43    Stores RouteResult for each (origin, destination) pair,44    enabling O(1) lookup during solver execution.45    """46 47    _matrix: dict[tuple[tuple[float, float], tuple[float, float]], RouteResult] = field(48        default_factory=dict49    )50 51    def _key(52        self, origin: "Location", destination: "Location"53    ) -> tuple[tuple[float, float], tuple[float, float]]:54        """Create hashable key from two locations."""55        return (56            (origin.latitude, origin.longitude),57            (destination.latitude, destination.longitude),58        )59 60    def set_route(61        self, origin: "Location", destination: "Location", result: RouteResult62    ) -> None:63        """Store a route result in the matrix."""64        self._matrix[self._key(origin, destination)] = result65 66    def get_route(67        self, origin: "Location", destination: "Location"68    ) -> Optional[RouteResult]:69        """Get a route result from the matrix."""70        return self._matrix.get(self._key(origin, destination))71 72    def get_driving_time(self, origin: "Location", destination: "Location") -> int:73        """Get driving time in seconds between two locations."""74        result = self.get_route(origin, destination)75        if result is None:76            # Fallback to haversine if not in matrix77            return _haversine_driving_time(origin, destination)78        return result.duration_seconds79 80    def get_geometry(81        self, origin: "Location", destination: "Location"82    ) -> Optional[str]:83        """Get encoded polyline geometry for a route segment."""84        result = self.get_route(origin, destination)85        return result.geometry if result else None86 87 88def _haversine_driving_time(origin: "Location", destination: "Location") -> int:89    """90    Calculate driving time using haversine formula (fallback).91 92    Uses 50 km/h average speed assumption.93    """94    if (95        origin.latitude == destination.latitude96        and origin.longitude == destination.longitude97    ):98        return 099 100    EARTH_RADIUS_M = 6371000101    AVERAGE_SPEED_KMPH = 50102 103    lat1 = math.radians(origin.latitude)104    lon1 = math.radians(origin.longitude)105    lat2 = math.radians(destination.latitude)106    lon2 = math.radians(destination.longitude)107 108    # Haversine formula109    dlat = lat2 - lat1110    dlon = lon2 - lon1111    a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2112    c = 2 * math.asin(math.sqrt(a))113    distance_meters = EARTH_RADIUS_M * c114 115    # Convert to driving time116    return round(distance_meters / AVERAGE_SPEED_KMPH * 3.6)117 118 119class OSMnxRoutingService:120    """121    Routing service using OSMnx for real road network data.122 123    Downloads the OSM network for a given bounding box, caches it locally,124    and computes shortest paths using NetworkX.125    """126 127    def __init__(self, cache_dir: Path = CACHE_DIR):128        self.cache_dir = cache_dir129        self.cache_dir.mkdir(parents=True, exist_ok=True)130        self._graph = None131        self._graph_bbox = None132 133    def _get_cache_path(134        self, north: float, south: float, east: float, west: float135    ) -> Path:136        """Generate cache file path for a bounding box."""137        # Round to 2 decimal places for cache key138        key = f"osm_{north:.2f}_{south:.2f}_{east:.2f}_{west:.2f}.graphml"139        return self.cache_dir / key140 141    def load_network(142        self, north: float, south: float, east: float, west: float, padding: float = 0.01143    ) -> bool:144        """145        Load OSM road network for the given bounding box.146 147        Args:148            north, south, east, west: Bounding box coordinates149            padding: Extra padding around the bbox (in degrees)150 151        Returns:152            True if network loaded successfully, False otherwise153        """154        try:155            import osmnx as ox156 157            # Add padding to ensure we have roads outside the strict bbox158            north += padding159            south -= padding160            east += padding161            west -= padding162 163            cache_path = self._get_cache_path(north, south, east, west)164 165            if cache_path.exists() and cache_path.stat().st_size > 0:166                logger.info(f"Loading cached OSM network from {cache_path}")167                self._graph = ox.load_graphml(cache_path)168 169                # Check if the cached graph already has travel_time170                # (we now save enriched graphs)171                sample_edge = next(iter(self._graph.edges(data=True)), None)172                has_travel_time = sample_edge and "travel_time" in sample_edge[2]173 174                if not has_travel_time:175                    logger.info("Adding edge speeds and travel times to cached graph...")176                    self._graph = ox.add_edge_speeds(self._graph)177                    self._graph = ox.add_edge_travel_times(self._graph)178                    # Re-save with travel times included179                    ox.save_graphml(self._graph, cache_path)180                    logger.info("Updated cache with travel times")181            else:182                logger.info(183                    f"Downloading OSM network for bbox: N={north:.4f}, S={south:.4f}, E={east:.4f}, W={west:.4f}"184                )185                # OSMnx 2.x uses bbox as tuple: (left, bottom, right, top) = (west, south, east, north)186                bbox_tuple = (west, south, east, north)187                self._graph = ox.graph_from_bbox(188                    bbox=bbox_tuple,189                    network_type="drive",190                    simplify=True,191                )192 193                # Add edge speeds and travel times BEFORE caching194                logger.info("Computing edge speeds and travel times...")195                self._graph = ox.add_edge_speeds(self._graph)196                self._graph = ox.add_edge_travel_times(self._graph)197 198                # Save enriched graph to cache199                ox.save_graphml(self._graph, cache_path)200                logger.info(f"Saved enriched OSM network to cache: {cache_path}")201 202            self._graph_bbox = (north, south, east, west)203            logger.info(204                f"OSM network loaded: {self._graph.number_of_nodes()} nodes, "205                f"{self._graph.number_of_edges()} edges"206            )207            return True208 209        except ImportError:210            logger.warning("OSMnx not installed, falling back to haversine")211            return False212        except Exception as e:213            logger.warning(f"Failed to load OSM network: {e}, falling back to haversine")214            return False215 216    def get_nearest_node(self, location: "Location") -> Optional[int]:217        """Get the nearest graph node for a location."""218        if self._graph is None:219            return None220        try:221            import osmnx as ox222            return ox.nearest_nodes(self._graph, location.longitude, location.latitude)223        except Exception:224            return None225 226    def compute_all_routes(227        self,228        locations: list["Location"],229        progress_callback=None230    ) -> dict[tuple[int, int], RouteResult]:231        """232        Compute all pairwise routes efficiently using batch shortest paths.233 234        Returns a dict mapping (origin_idx, dest_idx) to RouteResult.235        """236        import networkx as nx237 238        if self._graph is None:239            return {}240 241        results = {}242        n = len(locations)243 244        # Map locations to nearest nodes (batch operation)245        if progress_callback:246            progress_callback("routes", "Finding nearest road nodes...", 30, f"{n} locations")247 248        nodes = []249        for loc in locations:250            node = self.get_nearest_node(loc)251            nodes.append(node)252 253        # Compute shortest paths from each origin to ALL destinations at once254        # This is MUCH faster than individual shortest_path calls255        total_origins = sum(1 for node in nodes if node is not None)256        processed = 0257 258        for i, origin_node in enumerate(nodes):259            if origin_node is None:260                continue261 262            # Compute shortest paths from this origin to all nodes at once263            # Using Dijkstra's algorithm with single-source264            try:265                lengths, paths = nx.single_source_dijkstra(266                    self._graph, origin_node, weight="travel_time"267                )268            except nx.NetworkXError:269                continue270 271            for j, dest_node in enumerate(nodes):272                if dest_node is None:273                    continue274 275                origin_loc = locations[i]276                dest_loc = locations[j]277 278                if i == j or origin_node == dest_node:279                    # Same location280                    results[(i, j)] = RouteResult(281                        duration_seconds=0,282                        distance_meters=0,283                        geometry=polyline.encode(284                            [(origin_loc.latitude, origin_loc.longitude)], precision=5285                        ),286                    )287                elif dest_node in paths:288                    path = paths[dest_node]289                    travel_time = lengths[dest_node]290 291                    # Calculate distance and extract geometry292                    total_distance = 0293                    coordinates = []294 295                    for k in range(len(path) - 1):296                        u, v = path[k], path[k + 1]297                        edge_data = self._graph.get_edge_data(u, v)298                        if edge_data:299                            edge = edge_data[0] if isinstance(edge_data, dict) else edge_data300                            total_distance += edge.get("length", 0)301 302                    for node in path:303                        node_data = self._graph.nodes[node]304                        coordinates.append((node_data["y"], node_data["x"]))305 306                    results[(i, j)] = RouteResult(307                        duration_seconds=round(travel_time),308                        distance_meters=round(total_distance),309                        geometry=polyline.encode(coordinates, precision=5),310                    )311 312            processed += 1313            if progress_callback and processed % max(1, total_origins // 10) == 0:314                percent = 30 + int((processed / total_origins) * 65)315                progress_callback(316                    "routes",317                    "Computing routes...",318                    percent,319                    f"{processed}/{total_origins} origins processed"320                )321 322        return results323 324    def get_route(325        self, origin: "Location", destination: "Location"326    ) -> Optional[RouteResult]:327        """328        Compute route between two locations.329 330        Returns:331            RouteResult with duration, distance, and geometry, or None if routing fails332        """333        if self._graph is None:334            return None335 336        try:337            import osmnx as ox338 339            # Find nearest nodes to origin and destination340            origin_node = ox.nearest_nodes(341                self._graph, origin.longitude, origin.latitude342            )343            dest_node = ox.nearest_nodes(344                self._graph, destination.longitude, destination.latitude345            )346 347            # Same node means same location (or very close)348            if origin_node == dest_node:349                return RouteResult(350                    duration_seconds=0,351                    distance_meters=0,352                    geometry=polyline.encode(353                        [(origin.latitude, origin.longitude)], precision=5354                    ),355                )356 357            # Compute shortest path by travel time358            route = ox.shortest_path(359                self._graph, origin_node, dest_node, weight="travel_time"360            )361 362            if route is None:363                logger.warning(364                    f"No route found between {origin} and {destination}"365                )366                return None367 368            # Extract route attributes369            total_time = 0370            total_distance = 0371            coordinates = []372 373            for i in range(len(route) - 1):374                u, v = route[i], route[i + 1]375                edge_data = self._graph.get_edge_data(u, v)376                if edge_data:377                    # Get the first edge if multiple exist378                    edge = edge_data[0] if isinstance(edge_data, dict) else edge_data379                    total_time += edge.get("travel_time", 0)380                    total_distance += edge.get("length", 0)381 382            # Get node coordinates for geometry383            for node in route:384                node_data = self._graph.nodes[node]385                coordinates.append((node_data["y"], node_data["x"]))386 387            # Encode geometry as polyline388            encoded_geometry = polyline.encode(coordinates, precision=5)389 390            return RouteResult(391                duration_seconds=round(total_time),392                distance_meters=round(total_distance),393                geometry=encoded_geometry,394            )395 396        except Exception as e:397            logger.warning(f"Routing failed: {e}")398            return None399 400 401def compute_distance_matrix(402    locations: list["Location"],403    routing_service: Optional[OSMnxRoutingService] = None,404    bbox: Optional[tuple[float, float, float, float]] = None,405) -> DistanceMatrix:406    """407    Compute distance matrix for all location pairs.408 409    Args:410        locations: List of Location objects411        routing_service: Optional pre-configured routing service412        bbox: Optional (north, south, east, west) tuple for network download413 414    Returns:415        DistanceMatrix with precomputed routes416    """417    return compute_distance_matrix_with_progress(418        locations, routing_service, bbox, use_osm=True, progress_callback=None419    )420 421 422def compute_distance_matrix_with_progress(423    locations: list["Location"],424    bbox: Optional[tuple[float, float, float, float]] = None,425    use_osm: bool = True,426    progress_callback=None,427    routing_service: Optional[OSMnxRoutingService] = None,428) -> DistanceMatrix:429    """430    Compute distance matrix for all location pairs with progress reporting.431 432    Args:433        locations: List of Location objects434        bbox: Optional (north, south, east, west) tuple for network download435        use_osm: If True, try to use OSMnx for real routing. If False, use haversine.436        progress_callback: Optional callback(phase, message, percent, detail) for progress updates437        routing_service: Optional pre-configured routing service438 439    Returns:440        DistanceMatrix with precomputed routes441    """442    matrix = DistanceMatrix()443 444    if not locations:445        return matrix446 447    def report_progress(phase: str, message: str, percent: int, detail: str = ""):448        if progress_callback:449            progress_callback(phase, message, percent, detail)450        logger.info(f"[{phase}] {message} ({percent}%) {detail}")451 452    # Compute bounding box from locations if not provided453    if bbox is None:454        lats = [loc.latitude for loc in locations]455        lons = [loc.longitude for loc in locations]456        bbox = (max(lats), min(lats), max(lons), min(lons))457 458    osm_loaded = False459 460    if use_osm:461        # Create routing service if not provided462        if routing_service is None:463            routing_service = OSMnxRoutingService()464 465        report_progress("network", "Checking for cached road network...", 5)466 467        # Check if cached468        north, south, east, west = bbox469        north += 0.01  # padding470        south -= 0.01471        east += 0.01472        west -= 0.01473 474        cache_path = routing_service._get_cache_path(north, south, east, west)475        is_cached = cache_path.exists()476 477        if is_cached:478            report_progress("network", "Loading cached road network...", 10, str(cache_path.name))479        else:480            report_progress(481                "network",482                "Downloading OpenStreetMap road network...",483                10,484                f"Area: {abs(north-south):.2f}° × {abs(east-west):.2f}°"485            )486 487        # Try to load OSM network488        osm_loaded = routing_service.load_network(489            north=bbox[0], south=bbox[1], east=bbox[2], west=bbox[3]490        )491 492        if osm_loaded:493            node_count = routing_service._graph.number_of_nodes()494            edge_count = routing_service._graph.number_of_edges()495            report_progress(496                "network",497                "Road network loaded",498                25,499                f"{node_count:,} nodes, {edge_count:,} edges"500            )501        else:502            report_progress("network", "OSMnx unavailable, using haversine", 25)503    else:504        report_progress("network", "Using fast haversine mode", 25)505 506    # Compute all pairwise routes507    total_pairs = len(locations) * len(locations)508 509    if osm_loaded and routing_service:510        # Use batch routing for OSMnx (MUCH faster than individual calls)511        report_progress(512            "routes",513            f"Computing {total_pairs:,} routes (batch mode)...",514            30,515            f"{len(locations)} locations"516        )517 518        batch_results = routing_service.compute_all_routes(519            locations,520            progress_callback=report_progress521        )522 523        # Transfer batch results to matrix, with haversine fallback for missing routes524        computed = 0525        for i, origin in enumerate(locations):526            for j, destination in enumerate(locations):527                if (i, j) in batch_results:528                    matrix.set_route(origin, destination, batch_results[(i, j)])529                else:530                    # Fallback to haversine for routes not found531                    matrix.set_route(532                        origin,533                        destination,534                        RouteResult(535                            duration_seconds=_haversine_driving_time(origin, destination),536                            distance_meters=_haversine_distance_meters(origin, destination),537                            geometry=_straight_line_geometry(origin, destination),538                        ),539                    )540                computed += 1541 542        report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")543    else:544        # Use haversine fallback for all routes545        report_progress(546            "routes",547            f"Computing {total_pairs:,} route pairs...",548            30,549            f"{len(locations)} locations"550        )551 552        computed = 0553        for origin in locations:554            for destination in locations:555                if origin is destination:556                    matrix.set_route(557                        origin,558                        destination,559                        RouteResult(560                            duration_seconds=0,561                            distance_meters=0,562                            geometry=polyline.encode(563                                [(origin.latitude, origin.longitude)], precision=5564                            ),565                        ),566                    )567                else:568                    matrix.set_route(569                        origin,570                        destination,571                        RouteResult(572                            duration_seconds=_haversine_driving_time(origin, destination),573                            distance_meters=_haversine_distance_meters(origin, destination),574                            geometry=_straight_line_geometry(origin, destination),575                        ),576                    )577                computed += 1578 579                # Report progress every 5%580                if total_pairs > 0 and computed % max(1, total_pairs // 20) == 0:581                    percent_complete = int(30 + (computed / total_pairs) * 65)582                    report_progress(583                        "routes",584                        f"Computing routes...",585                        percent_complete,586                        f"{computed:,}/{total_pairs:,} pairs"587                    )588 589        report_progress("complete", "Distance matrix ready", 100, f"{computed:,} routes computed")590 591    return matrix592 593 594def _haversine_distance_meters(origin: "Location", destination: "Location") -> int:595    """Calculate haversine distance in meters."""596    if (597        origin.latitude == destination.latitude598        and origin.longitude == destination.longitude599    ):600        return 0601 602    EARTH_RADIUS_M = 6371000603 604    lat1 = math.radians(origin.latitude)605    lon1 = math.radians(origin.longitude)606    lat2 = math.radians(destination.latitude)607    lon2 = math.radians(destination.longitude)608 609    dlat = lat2 - lat1610    dlon = lon2 - lon1611    a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2612    c = 2 * math.asin(math.sqrt(a))613 614    return round(EARTH_RADIUS_M * c)615 616 617def _straight_line_geometry(origin: "Location", destination: "Location") -> str:618    """Generate a straight-line encoded polyline between two points."""619    return polyline.encode(620        [(origin.latitude, origin.longitude), (destination.latitude, destination.longitude)],621        precision=5,622    )623