11from enum import Enum
22from pathlib import Path
33import re
4- from typing import Callable
54
65import geopandas as gpd
76import httpx
@@ -50,8 +49,14 @@ def fetch_geojson_data(url: str, timeout: float = 30.0) -> str:
5049 httpx.RequestError: For network errors
5150 httpx.HTTPStatusError: For HTTP errors
5251 """
52+ headers = {}
53+
54+ # Add User-Agent for OpenStreetMap/Nominatim requests
55+ if "nominatim.openstreetmap.org" in url :
56+ headers ["User-Agent" ] = "jet-lag-munich/0.1.0 (https://github.com/cameronbrill/jet-lag-munich)"
57+
5358 with httpx .Client () as client :
54- response = client .get (url , timeout = timeout )
59+ response = client .get (url , timeout = timeout , headers = headers )
5560 response .raise_for_status ()
5661 return response .text
5762
@@ -70,6 +75,52 @@ def separate_geometries(gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.Ge
7075 return points_gdf , lines_gdf
7176
7277
78+ def extract_boundary_polygon (boundary_gdf : gpd .GeoDataFrame ) -> gpd .GeoDataFrame :
79+ """Extract boundary polygon from polygon/multipolygon geometries.
80+
81+ Args:
82+ boundary_gdf: GeoDataFrame containing polygon geometries
83+
84+ Returns:
85+ GeoDataFrame with Polygon geometry for Google My Maps area tinting
86+ """
87+
88+ logger = get_logger (__name__ )
89+ boundary_lines = []
90+
91+ for idx , row in boundary_gdf .iterrows ():
92+ geom = row .geometry
93+
94+ if geom .geom_type == "Polygon" :
95+ # Keep the polygon as-is for Google My Maps tinting
96+ boundary_polygon = geom
97+ elif geom .geom_type == "MultiPolygon" :
98+ # Find the largest polygon and keep it as a polygon
99+ boundary_polygon = max (geom .geoms , key = lambda p : p .area )
100+ else :
101+ # Skip non-polygon geometries
102+ logger .debug ("Skipping non-polygon geometry" , geom_type = geom .geom_type )
103+ continue
104+
105+ # Create new row with boundary polygon
106+ new_row = row .copy ()
107+ new_row .geometry = boundary_polygon
108+ new_row ["name" ] = "Munich Boundary"
109+ boundary_lines .append (new_row )
110+
111+ logger .info (
112+ "Extracted boundary polygon" ,
113+ geom_type = geom .geom_type ,
114+ boundary_points = len (boundary_polygon .exterior .coords ),
115+ area = boundary_polygon .area
116+ )
117+
118+ if boundary_lines :
119+ return gpd .GeoDataFrame (boundary_lines , crs = boundary_gdf .crs )
120+ # Return empty GeoDataFrame with same structure
121+ return gpd .GeoDataFrame (columns = boundary_gdf .columns , crs = boundary_gdf .crs )
122+
123+
73124def create_stations_csv (points_gdf : gpd .GeoDataFrame , endpoint_name : str ) -> pd .DataFrame :
74125 """Create CSV DataFrame for stations with Google My Maps compatible columns.
75126
@@ -97,9 +148,12 @@ def create_stations_csv(points_gdf: gpd.GeoDataFrame, endpoint_name: str) -> pd.
97148
98149 if "station_label" in stations_df .columns :
99150 # Use station_label value directly, fallback to rail-type specific description
100- station_label_application_func : Callable [[str ], str ] = lambda x : str (x ).strip () if pd .notna (x ) and str (x ).strip () != "" else fallback_description
151+ def _station_label_application_func (x : str ) -> str :
152+ if pd .notna (x ) and str (x ).strip () != "" :
153+ return str (x ).strip ()
154+ return fallback_description
101155 stations_df ["Description" ] = stations_df ["station_label" ].apply ( # pyright: ignore[reportUnknownMemberType]
102- station_label_application_func
156+ _station_label_application_func
103157 )
104158 else :
105159 stations_df ["Description" ] = fallback_description
@@ -282,7 +336,7 @@ class MunichGeoJson(str, Enum):
282336 SUBWAY_LIGHTRAIL = "https://loom.cs.uni-freiburg.de/components/subway-lightrail/13/component-220.json"
283337 TRAM = "https://loom.cs.uni-freiburg.de/components/tram/13/component-176.json"
284338 COMMUTER_RAIL = "https://loom.cs.uni-freiburg.de/components/rail-commuter/13/component-78.json"
285- BOUNDARY = "https://nominatim.openstreetmap.org/search.php?q=Munich&polygon_geojson=1&format=geojson"
339+ BOUNDARY = "https://nominatim.openstreetmap.org/search.php?q=Munich&polygon_geojson=1&format=geojson&countrycodes=de "
286340
287341
288342def main () -> None :
@@ -343,7 +397,39 @@ def main() -> None:
343397 gdf = gdf .to_crs ("EPSG:4326" )
344398 logger .info ("Converted CRS to WGS84" , endpoint = endpoint .name )
345399
346- # Separate geometries
400+ # Handle boundary data differently
401+ if endpoint .name == "BOUNDARY" :
402+ # Extract boundary polygon from polygon/multipolygon
403+ boundary_polygon_gdf = extract_boundary_polygon (gdf )
404+
405+ if len (boundary_polygon_gdf ) > 0 :
406+ # Create CSV for boundary (will use WKT POLYGON format for Google My Maps tinting)
407+ boundary_csv = create_lines_csv (boundary_polygon_gdf )
408+ boundary_csv_file = output_dir / "munich_boundary.csv"
409+ boundary_csv .to_csv (boundary_csv_file , index = False )
410+
411+ # Create KML for boundary
412+ boundary_kml = output_dir / "munich_boundary.kml"
413+ create_simple_kml (boundary_polygon_gdf , "munich_boundary" , boundary_kml )
414+
415+ logger .info (
416+ "Successfully converted boundary to CSV and KML" ,
417+ endpoint = endpoint .name ,
418+ csv_file = str (boundary_csv_file ),
419+ csv_size_bytes = boundary_csv_file .stat ().st_size ,
420+ kml_file = str (boundary_kml ),
421+ kml_size_bytes = boundary_kml .stat ().st_size ,
422+ boundary_features = len (boundary_polygon_gdf ),
423+ )
424+ else :
425+ logger .warning ("No boundary data found" , endpoint = endpoint .name )
426+
427+ # Clean up and continue to next endpoint
428+ temp_geojson .unlink ()
429+ logger .debug ("Cleaned up temporary file" , temp_file = str (temp_geojson ))
430+ continue
431+
432+ # Separate geometries for transit data
347433 points_gdf , lines_gdf = separate_geometries (gdf )
348434
349435 # Google My Maps limits: max 2,000 rows, 5MB file size
0 commit comments