File size: 8,818 Bytes
4e22ad8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""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}")