Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

Commit 32d898c

Browse files
committed
boundaries plz
1 parent 437a8ff commit 32d898c

2 files changed

Lines changed: 169 additions & 9 deletions

File tree

core/map/main.py

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from enum import Enum
22
from pathlib import Path
33
import re
4-
from typing import Callable
54

65
import geopandas as gpd
76
import 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+
73124
def 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

288342
def 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

tests/map/test_main.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
create_lines_csv,
1616
create_simple_kml,
1717
create_stations_csv,
18+
extract_boundary_polygon,
1819
extract_line_name,
1920
fetch_geojson_data,
2021
main,
@@ -86,7 +87,7 @@ def test_fetches_data_successfully(self, mock_client_class):
8687
result = fetch_geojson_data("https://example.com/data.json")
8788

8889
assert result == '{"type": "FeatureCollection"}'
89-
mock_client.get.assert_called_once_with("https://example.com/data.json", timeout=30.0)
90+
mock_client.get.assert_called_once_with("https://example.com/data.json", timeout=30.0, headers={})
9091

9192
@patch("core.map.main.httpx.Client")
9293
def test_raises_request_error_on_network_failure(self, mock_client_class):
@@ -203,6 +204,79 @@ def test_includes_description_with_station_names(self):
203204
assert result.iloc[1]["Description"] == "Unknown Subway Station" # Rail-type specific fallback
204205

205206

207+
class TestExtractBoundaryLine:
208+
"""Test extraction of boundary line from polygon/multipolygon geometries."""
209+
210+
def test_preserves_polygon_geometry_for_google_maps_tinting(self):
211+
"""Should preserve polygon geometry to enable Google My Maps area tinting."""
212+
from shapely.geometry import Polygon
213+
214+
# Create a simple polygon (square)
215+
polygon = Polygon([(11.0, 48.0), (11.1, 48.0), (11.1, 48.1), (11.0, 48.1), (11.0, 48.0)])
216+
data = {
217+
"geometry": [polygon],
218+
"name": ["Munich"]
219+
}
220+
boundary_gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")
221+
222+
result = extract_boundary_polygon(boundary_gdf)
223+
224+
# Should return a GeoDataFrame with Polygon geometry (not LineString)
225+
assert len(result) == 1
226+
assert result.iloc[0].geometry.geom_type == "Polygon"
227+
assert "name" in result.columns
228+
assert result.iloc[0]["name"] == "Munich Boundary"
229+
230+
def test_handles_multipolygon_by_taking_largest(self):
231+
"""Should extract boundary from the largest polygon in a MultiPolygon."""
232+
from shapely.geometry import MultiPolygon, Polygon
233+
234+
# Create MultiPolygon with different sized polygons
235+
small_poly = Polygon([(11.0, 48.0), (11.01, 48.0), (11.01, 48.01), (11.0, 48.01), (11.0, 48.0)])
236+
large_poly = Polygon([(11.0, 48.0), (11.2, 48.0), (11.2, 48.2), (11.0, 48.2), (11.0, 48.0)])
237+
multipolygon = MultiPolygon([small_poly, large_poly])
238+
239+
data = {
240+
"geometry": [multipolygon],
241+
"name": ["Munich"]
242+
}
243+
boundary_gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")
244+
245+
result = extract_boundary_polygon(boundary_gdf)
246+
247+
# Should extract the larger polygon
248+
assert len(result) == 1
249+
assert result.iloc[0].geometry.geom_type == "Polygon"
250+
# The boundary should have more area (from the larger polygon)
251+
assert result.iloc[0].geometry.area > 0.01
252+
253+
def test_creates_polygon_wkt_for_google_maps_tinting(self):
254+
"""Should create POLYGON WKT format for Google My Maps area tinting."""
255+
from shapely.geometry import Polygon
256+
257+
# Create a simple polygon
258+
polygon = Polygon([(11.0, 48.0), (11.1, 48.0), (11.1, 48.1), (11.0, 48.1), (11.0, 48.0)])
259+
data = {
260+
"geometry": [polygon],
261+
"name": ["Munich"]
262+
}
263+
boundary_gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")
264+
265+
result = extract_boundary_polygon(boundary_gdf)
266+
267+
# Should preserve polygon for area tinting
268+
assert len(result) == 1
269+
assert result.iloc[0].geometry.geom_type == "Polygon"
270+
271+
# When converted to CSV, should create POLYGON WKT
272+
boundary_csv = create_lines_csv(result)
273+
wkt_value = boundary_csv.iloc[0]["WKT"]
274+
assert wkt_value.startswith("POLYGON")
275+
# The name will be "Unknown Line" since boundary polygons don't have dbg_lines
276+
# but the important thing is we get POLYGON WKT format
277+
assert boundary_csv.iloc[0]["name"] == "Unknown Line"
278+
279+
206280
class TestCreateSimpleKml:
207281
"""Test creation of simplified KML files with Google My Maps compatible attributes."""
208282

@@ -379,5 +453,5 @@ def test_main_processes_all_endpoints(self, mock_fetch):
379453
# This should not raise any exceptions
380454
main()
381455

382-
# Should have called fetch for all endpoints
383-
assert mock_fetch.call_count == 3
456+
# Should have called fetch for all endpoints (including BOUNDARY)
457+
assert mock_fetch.call_count == 4

0 commit comments

Comments
 (0)