Spaces:
Sleeping
Sleeping
| """Build and persist a road graph covering Granollers and Mollet del Vallès. | |
| Within each city we keep the full drivable network so deliveries can be routed | |
| to any address. Between the cities we only keep major roads (motorway, trunk, | |
| primary) since the truck just needs the fastest corridor from one to the other. | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import json | |
| from pathlib import Path | |
| import matplotlib.pyplot as plt | |
| import networkx as nx | |
| import osmnx as ox | |
| from shapely.ops import unary_union | |
| GRANOLLERS = "Granollers, Catalunya, Spain" | |
| MOLLET_DEL_VALLES = "Mollet del Vallès, Catalunya, Spain" | |
| NETWORK_TYPE = "drive" | |
| # Inter-city corridor: only fast roads. Includes link ramps so the corridor | |
| # actually connects to the local networks at on/off-ramps. | |
| MAJOR_ROAD_FILTER = ( | |
| '["highway"~"motorway|trunk|primary|' | |
| 'motorway_link|trunk_link|primary_link"]' | |
| ) | |
| DEFAULT_GRAPH_PATH = Path(__file__).with_name("cities_graph.graphml") | |
| DEFAULT_COORDS_PATH = Path(__file__).with_name("coords.csv") | |
| DEFAULT_REQUEST_PATH = Path(__file__).with_name("data") / "sample_request.json" | |
| def load_depot_coords(path: Path | str = DEFAULT_REQUEST_PATH) -> tuple[float, float] | None: | |
| """Return (lat, lng) of the depot if `sample_request.json` exists, else None.""" | |
| path = Path(path) | |
| if not path.exists(): | |
| return None | |
| req = json.loads(path.read_text(encoding="utf-8")) | |
| c = req.get("depot", {}).get("coords") | |
| if not c: | |
| return None | |
| return float(c["lat"]), float(c["lng"]) | |
| def _draw_depot(ax, depot: tuple[float, float] | None) -> None: | |
| if depot is None: | |
| return | |
| lat, lng = depot | |
| ax.scatter( | |
| [lng], [lat], | |
| s=160, c="#d62728", marker="*", zorder=10, | |
| edgecolors="black", linewidth=1.0, | |
| label="Warehouse", | |
| ) | |
| def _city_graph(place: str) -> nx.MultiDiGraph: | |
| return ox.graph_from_place(place, network_type=NETWORK_TYPE) | |
| def _corridor_graph(places: list[str]) -> nx.MultiDiGraph: | |
| """Major-roads-only graph spanning the bounding box of the given places.""" | |
| boundaries = [ox.geocode_to_gdf(p).geometry.iloc[0] for p in places] | |
| minx, miny, maxx, maxy = unary_union(boundaries).bounds | |
| return ox.graph_from_bbox( | |
| bbox=(minx, miny, maxx, maxy), | |
| custom_filter=MAJOR_ROAD_FILTER, | |
| truncate_by_edge=True, | |
| retain_all=False, | |
| ) | |
| def build_combined_graph() -> nx.MultiDiGraph: | |
| """Return a single graph with both cities plus the minimal corridor between.""" | |
| g_granollers = _city_graph(GRANOLLERS) | |
| g_mollet = _city_graph(MOLLET_DEL_VALLES) | |
| g_corridor = _corridor_graph([GRANOLLERS, MOLLET_DEL_VALLES]) | |
| combined = nx.compose_all([g_granollers, g_mollet, g_corridor]) | |
| # Preserve graph-level metadata (CRS) that compose_all drops. | |
| combined.graph.update(g_granollers.graph) | |
| # Drop disconnected fragments left over after composition. | |
| combined = ox.truncate.largest_component(combined, strongly=True) | |
| # Enrich edges with speed_kph + travel_time (seconds) so downstream | |
| # routing can use realistic times instead of raw distance. | |
| combined = ox.routing.add_edge_speeds(combined) | |
| combined = ox.routing.add_edge_travel_times(combined) | |
| return combined | |
| def save_graph(graph: nx.MultiDiGraph, path: Path | str = DEFAULT_GRAPH_PATH) -> Path: | |
| path = Path(path) | |
| ox.save_graphml(graph, path) | |
| return path | |
| def load_graph(path: Path | str = DEFAULT_GRAPH_PATH) -> nx.MultiDiGraph: | |
| return ox.load_graphml(Path(path)) | |
| def get_or_build_graph(path: Path | str = DEFAULT_GRAPH_PATH) -> nx.MultiDiGraph: | |
| path = Path(path) | |
| if path.exists(): | |
| return load_graph(path) | |
| graph = build_combined_graph() | |
| save_graph(graph, path) | |
| return graph | |
| def load_dropoff_coords(path: Path | str = DEFAULT_COORDS_PATH) -> list[tuple[float, float]]: | |
| """Read drop-off coordinates from a CSV with columns `id, x, y` (x=lng, y=lat).""" | |
| points: list[tuple[float, float]] = [] | |
| with open(path, newline="", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| points.append((float(row["y"]), float(row["x"]))) | |
| return points | |
| def _edge_colors(graph: nx.MultiDiGraph) -> list[str]: | |
| keywords = ("motorway", "trunk", "primary") | |
| # Iterate with keys=True so the list aligns 1:1 with osmnx's internal | |
| # edge ordering in MultiDiGraph plotting. | |
| return [ | |
| "#d62728" if any(k in str(data.get("highway", "")) for k in keywords) else "#444444" | |
| for _, _, _, data in graph.edges(keys=True, data=True) | |
| ] | |
| def plot_graph( | |
| graph: nx.MultiDiGraph, | |
| save_path: Path | str | None = None, | |
| depot: tuple[float, float] | None = None, | |
| ) -> Path | None: | |
| """Show the graph with city streets in grey and the inter-city corridor in red. | |
| Always writes a PNG (default `cities_graph.png` next to this file) because the | |
| interactive tkagg window can fail to render edges on some Windows setups — | |
| the file is the source of truth. | |
| """ | |
| fig, ax = ox.plot_graph( | |
| graph, | |
| figsize=(12, 12), | |
| edge_color=_edge_colors(graph), | |
| edge_linewidth=1.2, | |
| edge_alpha=1.0, | |
| node_size=3, | |
| node_color="#1f77b4", | |
| bgcolor="white", | |
| show=False, | |
| close=False, | |
| ) | |
| _draw_depot(ax, depot) | |
| if depot is not None: | |
| ax.legend(loc="upper right", framealpha=0.9) | |
| ax.set_title( | |
| f"Granollers + Mollet del Vallès " | |
| f"({graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges)" | |
| ) | |
| out = Path(save_path) if save_path else Path(__file__).with_name("cities_graph.png") | |
| fig.savefig(out, dpi=120, bbox_inches="tight") | |
| plt.show() | |
| return out | |
| def plot_graph_with_dropoffs( | |
| graph: nx.MultiDiGraph, | |
| coords: list[tuple[float, float]], | |
| title: str | None = None, | |
| save_path: Path | str | None = None, | |
| depot: tuple[float, float] | None = None, | |
| ) -> Path | None: | |
| """Overlay drop-off coordinates on the road graph. | |
| `coords` is a list of (lat, lng) pairs (matches `load_dropoff_coords`). | |
| Axis limits expand to include any drop-offs that fall outside the graph | |
| extent so off-area points are still visible (they may indicate the graph | |
| needs to be rebuilt over a different region). | |
| """ | |
| fig, ax = ox.plot_graph( | |
| graph, | |
| figsize=(12, 12), | |
| edge_color=_edge_colors(graph), | |
| edge_linewidth=1.2, | |
| edge_alpha=1.0, | |
| node_size=3, | |
| node_color="#1f77b4", | |
| bgcolor="white", | |
| show=False, | |
| close=False, | |
| ) | |
| pad = 0.005 | |
| node_lngs = [graph.nodes[n]["x"] for n in graph.nodes()] | |
| node_lats = [graph.nodes[n]["y"] for n in graph.nodes()] | |
| xmin, xmax = min(node_lngs) - pad, max(node_lngs) + pad | |
| ymin, ymax = min(node_lats) - pad, max(node_lats) + pad | |
| inside = [(lat, lng) for lat, lng in coords if xmin <= lng <= xmax and ymin <= lat <= ymax] | |
| outside_count = len(coords) - len(inside) | |
| if outside_count: | |
| print( | |
| f"warning: {outside_count}/{len(coords)} drop-offs fall outside the " | |
| f"graph extent and were cropped from the plot" | |
| ) | |
| lats = [c[0] for c in inside] | |
| lngs = [c[1] for c in inside] | |
| ax.scatter( | |
| lngs, lats, | |
| s=10, c="#2ca02c", alpha=0.7, zorder=5, | |
| edgecolors="white", linewidth=0.3, | |
| label=f"{len(inside)} drop-offs (in extent)", | |
| ) | |
| _draw_depot(ax, depot) | |
| ax.set_xlim(xmin, xmax) | |
| ax.set_ylim(ymin, ymax) | |
| ax.legend(loc="upper right", framealpha=0.9) | |
| ax.set_title( | |
| title | |
| or f"Graph + {len(coords)} drop-offs " | |
| f"({graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges)" | |
| ) | |
| out = Path(save_path) if save_path else Path(__file__).with_name("cities_graph_dropoffs.png") | |
| fig.savefig(out, dpi=120, bbox_inches="tight") | |
| plt.show() | |
| return out | |
| if __name__ == "__main__": | |
| g = build_combined_graph() | |
| print(f"Nodes: {g.number_of_nodes()} Edges: {g.number_of_edges()}") | |
| out = save_graph(g) | |
| print(f"Saved to {out}") | |
| depot = load_depot_coords() | |
| if depot is not None: | |
| print(f"Depot at lat={depot[0]:.4f}, lng={depot[1]:.4f}") | |
| if DEFAULT_COORDS_PATH.exists(): | |
| coords = load_dropoff_coords(DEFAULT_COORDS_PATH) | |
| print(f"Loaded {len(coords)} drop-off points from {DEFAULT_COORDS_PATH.name}") | |
| png = plot_graph_with_dropoffs(g, coords, depot=depot) | |
| else: | |
| png = plot_graph(g, depot=depot) | |
| print(f"Saved plot to {png}") | |