diff --git a/city2graph/data.py b/city2graph/data.py index 1e222cb..7b0f134 100644 --- a/city2graph/data.py +++ b/city2graph/data.py @@ -8,22 +8,28 @@ """ # Standard library imports +import io import json import subprocess +import warnings from pathlib import Path # Third-party imports import geopandas as gpd import pandas as pd +from geopy.geocoders import Nominatim from overturemaps.core import ALL_RELEASES from pyproj import CRS from shapely.geometry import LineString from shapely.geometry import MultiLineString +from shapely.geometry import MultiPolygon from shapely.geometry import Polygon from shapely.ops import substring +from .utils import clip_graph + # Public API definition -__all__ = ["load_overture_data", "process_overture_segments"] +__all__ = ["get_boundaries", "load_overture_data", "process_overture_segments"] # ============================================================================= # CONSTANTS AND CONFIGURATION @@ -53,7 +59,8 @@ def load_overture_data( - area: list[float] | Polygon, + area: list[float] | Polygon | MultiPolygon | gpd.GeoSeries | gpd.GeoDataFrame | None = None, + place_name: str | None = None, types: list[str] | None = None, output_dir: str = ".", prefix: str = "", @@ -63,6 +70,7 @@ def load_overture_data( connect_timeout: float | None = None, request_timeout: float | None = None, use_stac: bool = True, + **kwargs: bool, ) -> dict[str, gpd.GeoDataFrame]: """ Load data from Overture Maps using the CLI tool and optionally save to GeoJSON files. @@ -73,9 +81,15 @@ def load_overture_data( Parameters ---------- - area : list[float] or Polygon - The area of interest. Can be either a bounding box as [min_lon, min_lat, max_lon, max_lat] - or a Polygon geometry. + area : list[float], Polygon, MultiPolygon, GeoSeries, or GeoDataFrame, optional + The area of interest. Can be: + - A bounding box as [min_lon, min_lat, max_lon, max_lat] in WGS84 + - A Shapely Polygon/MultiPolygon (assumed to be in WGS84) + - A GeoSeries or GeoDataFrame with CRS info (will be reprojected to WGS84 if needed) + Mutually exclusive with place_name. + place_name : str, optional + Name of a place to geocode (e.g., "Liverpool, UK"). Uses Nominatim to retrieve + the boundary polygon. Mutually exclusive with area. types : list[str], optional List of Overture data types to download. If None, downloads all available types. @@ -122,6 +136,10 @@ def load_overture_data( use_stac : bool, default True Whether to use Overture's STAC-geoparquet catalog to speed up queries. If False, data will be read normally without the STAC optimization. + **kwargs + Additional keyword arguments: + - keep_outer_neighbors (bool, default False): Whether to keep segments that + partially intersect the boundary (True) or only those fully within (False). Returns ------- @@ -161,6 +179,14 @@ def load_overture_data( >>> # Download without STAC optimization >>> data = load_overture_data(bbox, types=['building'], use_stac=False) """ + # Validate area/place_name mutual exclusion + if (area is None) == (place_name is None): + msg = "Exactly one of 'area' or 'place_name' must be provided" + raise ValueError(msg) + + if place_name is not None: + area = get_boundaries(place_name).geometry.iloc[0] + # Validate input parameters types = types or list(VALID_OVERTURE_TYPES) invalid_types = [t for t in types if t not in VALID_OVERTURE_TYPES] @@ -195,6 +221,7 @@ def load_overture_data( connect_timeout, request_timeout, use_stac, + keep_outer_neighbors=kwargs.get("keep_outer_neighbors", False), ) if return_data: result[data_type] = gdf @@ -202,6 +229,62 @@ def load_overture_data( return result +def get_boundaries(place_name: str, user_agent: str = "city2graph") -> gpd.GeoDataFrame: + """ + Retrieve polygon boundary for a place using Nominatim geocoding. + + Uses the Nominatim geocoding service to find the geographic boundary + of a named place (city, country, region) and returns it as a GeoDataFrame. + + Parameters + ---------- + place_name : str + Name of the place to geocode (e.g., "Liverpool, UK"). + user_agent : str, default "city2graph" + User agent string for Nominatim API. + + Returns + ------- + geopandas.GeoDataFrame + GeoDataFrame with polygon geometry and place_name property. + + Raises + ------ + ValueError + If place is not found or returns non-polygon geometry. + + Examples + -------- + >>> boundary = get_boundaries("Liverpool, UK") + >>> data = load_overture_data(area=boundary.geometry.iloc[0], types=['building']) + """ + # Get all geocoding results (not just the first one) + locations = Nominatim(user_agent=user_agent).geocode( + place_name, geometry="geojson", exactly_one=False + ) + + if not locations: + msg = f"Place not found: '{place_name}'" + raise ValueError(msg) + + # Find the first result with a Polygon or MultiPolygon geometry + geojson = None + for location in locations: + geom = location.raw.get("geojson") + if geom is not None and geom.get("type") in ("Polygon", "MultiPolygon"): + geojson = geom + break + + if geojson is None: + msg = f"No polygon boundary for '{place_name}'. Try an administrative region." + raise ValueError(msg) + + return gpd.GeoDataFrame.from_features( + [{"type": "Feature", "geometry": geojson, "properties": {"place_name": place_name}}], + crs=WGS84_CRS, + ) + + def process_overture_segments( segments_gdf: gpd.GeoDataFrame, get_barriers: bool = True, @@ -254,6 +337,15 @@ def process_overture_segments( if segments_gdf.empty: return segments_gdf + # Warn if CRS is geographic or missing + if segments_gdf.crs is None or segments_gdf.crs == WGS84_CRS: + warnings.warn( + "Segments GeoDataFrame has no CRS or is in WGS84 (EPSG:4326). " + "Projected CRS is recommended for accurate length calculation and processing.", + UserWarning, + stacklevel=2, + ) + # Initialize result and ensure required columns exist result_gdf = segments_gdf.copy() if "level_rules" not in result_gdf.columns: @@ -278,7 +370,9 @@ def process_overture_segments( return result_gdf -def _prepare_area_and_bbox(area: list[float] | Polygon) -> tuple[str, Polygon | None]: +def _prepare_area_and_bbox( + area: list[float] | Polygon | MultiPolygon | gpd.GeoSeries | gpd.GeoDataFrame, +) -> tuple[str, Polygon | MultiPolygon | None]: """ Prepare area input and convert to bbox string and clipping geometry. @@ -287,14 +381,16 @@ def _prepare_area_and_bbox(area: list[float] | Polygon) -> tuple[str, Polygon | Parameters ---------- - area : list[float] or Polygon - The area of interest. Can be either a bounding box as [min_lon, min_lat, max_lon, max_lat] - or a Polygon geometry. + area : list[float], Polygon, MultiPolygon, GeoSeries, or GeoDataFrame + The area of interest. Can be: + - A bounding box as [min_lon, min_lat, max_lon, max_lat] in WGS84 + - A Shapely Polygon/MultiPolygon (assumed to be in WGS84) + - A GeoSeries or GeoDataFrame with CRS info (will be reprojected to WGS84 if needed) Returns ------- - tuple[str, Polygon or None] - Tuple containing bbox string and optional clipping geometry. + tuple[str, Polygon | MultiPolygon | None] + Tuple containing bbox string and optional clipping geometry (in WGS84). See Also -------- @@ -307,13 +403,17 @@ def _prepare_area_and_bbox(area: list[float] | Polygon) -> tuple[str, Polygon | >>> bbox_str '-74.1,40.7,-74.0,40.8' """ - if isinstance(area, Polygon): - # Convert to WGS84 if needed - area_wgs84 = ( - area.to_crs(WGS84_CRS) if hasattr(area, "crs") and area.crs != WGS84_CRS else area - ) - bbox_str = ",".join(str(round(c, 10)) for c in area_wgs84.bounds) - clip_geom = area_wgs84 + # Handle GeoDataFrame/GeoSeries - extract geometry and reproject if needed + if isinstance(area, (gpd.GeoDataFrame, gpd.GeoSeries)): + wgs84_crs = CRS.from_epsg(4326) + if area.crs is not None and CRS.from_user_input(area.crs) != wgs84_crs: + area = area.to_crs(wgs84_crs) + # Extract the first geometry + area = area.geometry.iloc[0] if isinstance(area, gpd.GeoDataFrame) else area.iloc[0] + + if isinstance(area, (Polygon, MultiPolygon)): + bbox_str = ",".join(str(round(c, 10)) for c in area.bounds) + clip_geom = area else: bbox_str = ",".join(str(float(b)) for b in area) clip_geom = None @@ -321,18 +421,19 @@ def _prepare_area_and_bbox(area: list[float] | Polygon) -> tuple[str, Polygon | return bbox_str, clip_geom -def _download_and_process_type( +def _download_and_process_type( # noqa: PLR0912, PLR0913, C901 data_type: str, bbox_str: str, output_dir: str, prefix: str, save_to_file: bool, return_data: bool, - clip_geom: Polygon | None, + clip_geom: Polygon | MultiPolygon | None, release: str | None = None, connect_timeout: float | None = None, request_timeout: float | None = None, use_stac: bool = True, + keep_outer_neighbors: bool = False, ) -> gpd.GeoDataFrame: """ Download and process a single data type from Overture Maps. @@ -354,7 +455,7 @@ def _download_and_process_type( Whether to save data to file. return_data : bool Whether to return the data. - clip_geom : Polygon or None + clip_geom : Polygon, MultiPolygon, or None Optional geometry for precise clipping. release : str or None Overture Maps release version to use. @@ -368,6 +469,8 @@ def _download_and_process_type( use_stac : bool, default True Whether to use Overture's STAC-geoparquet catalog to speed up queries. If False, data will be read normally without the STAC optimization. + keep_outer_neighbors : bool, default False + Whether to keep segments that partially intersect the boundary. Returns ------- @@ -398,17 +501,31 @@ def _download_and_process_type( if save_to_file: cmd.extend(["-o", str(output_path)]) - subprocess.run(cmd, check=True, capture_output=not save_to_file, text=True) + result = subprocess.run(cmd, check=True, capture_output=not save_to_file, text=True) if not return_data: return gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) # Load and clip data if needed - gdf = ( - gpd.read_file(output_path) - if save_to_file and output_path.exists() - else gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) - ) + if save_to_file and output_path.exists(): + gdf = gpd.read_file(output_path) + elif not save_to_file and result.stdout and isinstance(result.stdout, str): + # Parse GeoJSON from subprocess stdout when not saving to file + # The CLI may output warning messages before the GeoJSON, so we need to + # find where the actual JSON starts (either { for object or [ for array) + stdout = result.stdout + json_start = -1 + for i, char in enumerate(stdout): + if char in "{[": + json_start = i + break + gdf = ( + gpd.read_file(io.StringIO(stdout[json_start:])) + if json_start >= 0 + else gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) + ) + else: + gdf = gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) if clip_geom is not None and not gdf.empty: clip_gdf = gpd.GeoDataFrame(geometry=[clip_geom], crs=WGS84_CRS) @@ -421,7 +538,25 @@ def _download_and_process_type( if clip_crs_valid and gdf_crs_valid: if clip_gdf.crs != gdf.crs: clip_gdf = clip_gdf.to_crs(gdf.crs) - gdf = gpd.clip(gdf, clip_gdf) + + if data_type == "segment": + # For segments, use topological subsetting to preserve network integrity + gdf = clip_graph( + gdf, clip_gdf.geometry.iloc[0], keep_outer_neighbors=keep_outer_neighbors + ) + else: + # For visual features (buildings, etc.), use geometric clipping + gdf = gpd.clip(gdf, clip_gdf) + + # Filter non-LineString geometries for segments + if data_type == "segment" and not gdf.empty: + # Keep only LineStrings and explode MultiLineString + lines = gdf[gdf.geometry.type == "LineString"] + multi = gdf[gdf.geometry.type == "MultiLineString"] + if not multi.empty: + exploded = multi.explode(index_parts=False) + lines = pd.concat([lines, exploded]) + gdf = lines.reset_index(drop=True) return gdf diff --git a/city2graph/utils.py b/city2graph/utils.py index 5e32b96..1879e51 100644 --- a/city2graph/utils.py +++ b/city2graph/utils.py @@ -55,6 +55,7 @@ # Public API definition __all__ = [ + "clip_graph", "create_isochrone", "create_tessellation", "dual_graph", @@ -63,6 +64,7 @@ "nx_to_gdf", "nx_to_rx", "plot_graph", + "remove_isolated_components", "rx_to_nx", "validate_gdf", "validate_nx", @@ -4756,3 +4758,203 @@ def _plot_homo_graph( if nodes is not None and isinstance(nodes, gpd.GeoDataFrame): style_kwargs = _resolve_style_kwargs(kwargs, None, is_edge=False) _plot_gdf(nodes, ax, **style_kwargs) + + +def _validate_graph_input( + graph: gpd.GeoDataFrame | tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] | nx.Graph | nx.MultiGraph, +) -> tuple[gpd.GeoDataFrame | None, gpd.GeoDataFrame, str]: + """ + Validate graph input and extract nodes/edges GeoDataFrames. + + Converts various graph representations to a common format for internal processing. + + Parameters + ---------- + graph : GeoDataFrame, tuple, or NetworkX graph + Input graph in any supported city2graph format. + + Returns + ------- + tuple[GeoDataFrame | None, GeoDataFrame, str] + Tuple of (nodes_gdf, edges_gdf, input_type). + """ + if isinstance(graph, (nx.Graph, nx.MultiGraph)): + return *nx_to_gdf(graph, nodes=True, edges=True), "nx" + if isinstance(graph, tuple) and len(graph) == 2: + return graph[0], graph[1], "tuple" + if isinstance(graph, gpd.GeoDataFrame): + return None, graph, "gdf" + msg = "Input must be GeoDataFrame, (nodes, edges) tuple, or NetworkX graph." + raise TypeError(msg) + + +def _return_graph_output( + nodes: gpd.GeoDataFrame | None, + edges: gpd.GeoDataFrame, + input_type: str, + as_nx: bool, +) -> gpd.GeoDataFrame | tuple[gpd.GeoDataFrame | None, gpd.GeoDataFrame] | nx.Graph: + """ + Return graph in format matching input type or as NetworkX if requested. + + Standardizes output format logic for graph manipulation functions. + + Parameters + ---------- + nodes : GeoDataFrame or None + Nodes GeoDataFrame, or None for edges-only graphs. + edges : GeoDataFrame + Edges GeoDataFrame. + input_type : str + Original input type: "gdf", "tuple", or "nx". + as_nx : bool + If True, return as NetworkX graph. + + Returns + ------- + GeoDataFrame, tuple, or NetworkX graph + Graph in the requested output format. + """ + if as_nx or input_type == "nx": + return gdf_to_nx(nodes=nodes, edges=edges) if nodes is not None else gdf_to_nx(edges=edges) + return (nodes, edges) if input_type == "tuple" else edges + + +def _filter_nodes_by_edges( + nodes: gpd.GeoDataFrame | None, + edges: gpd.GeoDataFrame, +) -> gpd.GeoDataFrame | None: + """ + Filter nodes to only those connected to edges via MultiIndex. + + Extracts node IDs from edge MultiIndex and filters nodes accordingly. + + Parameters + ---------- + nodes : GeoDataFrame or None + Nodes GeoDataFrame to filter, or None. + edges : GeoDataFrame + Edges GeoDataFrame with MultiIndex for connectivity info. + + Returns + ------- + GeoDataFrame or None + Filtered nodes GeoDataFrame, or None if input was None. + """ + if nodes is None: + return None + if nodes.empty or edges.empty: + return nodes.iloc[0:0].copy() + if isinstance(edges.index, pd.MultiIndex) and edges.index.nlevels >= 2: + connected = set(edges.index.get_level_values(0)) | set(edges.index.get_level_values(1)) + return nodes[nodes.index.isin(connected)].copy() + return nodes.copy() + + +def clip_graph( + graph: gpd.GeoDataFrame | tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] | nx.Graph | nx.MultiGraph, + area: Polygon | MultiPolygon | gpd.GeoDataFrame | gpd.GeoSeries, + keep_outer_neighbors: bool = False, + as_nx: bool = False, +) -> gpd.GeoDataFrame | tuple[gpd.GeoDataFrame | None, gpd.GeoDataFrame] | nx.Graph: + """ + Clip a graph to a specific area. + + Filters edges to those within (or intersecting) a polygon area. + Nodes are filtered to include only those connected to remaining edges. + + Parameters + ---------- + graph : GeoDataFrame, tuple, or NetworkX graph + Input graph as edges GeoDataFrame, (nodes, edges) tuple, or NetworkX graph. + area : Polygon, MultiPolygon, GeoDataFrame, or GeoSeries + The area to clip to. + keep_outer_neighbors : bool, default False + If True, keeps segments that intersect the boundary. + as_nx : bool, default False + If True, return as NetworkX graph. + + Returns + ------- + GeoDataFrame, tuple, or NetworkX graph + Clipped graph in same format as input, or NetworkX if as_nx=True. + """ + nodes, edges, input_type = _validate_graph_input(graph) + + if edges.empty: + return _return_graph_output(nodes, edges, input_type, as_nx) + + # Extract clip geometry + clip_geom = ( + (area.geometry.union_all() if len(area) > 1 else area.geometry.iloc[0]) + if isinstance(area, (gpd.GeoDataFrame, gpd.GeoSeries)) + else area + ) + + # Filter or clip edges + clipped_edges = ( + edges[edges.geometry.intersects(clip_geom)].copy() + if keep_outer_neighbors + else gpd.clip(edges, clip_geom) + ) + + # Explode MultiLineStrings (created by clipping or existing) + if not clipped_edges.empty and "MultiLineString" in clipped_edges.geometry.type.to_numpy(): + clipped_edges = clipped_edges.explode(ignore_index=True) + + return _return_graph_output( + _filter_nodes_by_edges(nodes, clipped_edges), clipped_edges, input_type, as_nx + ) + + +def remove_isolated_components( + graph: gpd.GeoDataFrame | tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] | nx.Graph | nx.MultiGraph, + as_nx: bool = False, +) -> gpd.GeoDataFrame | tuple[gpd.GeoDataFrame | None, gpd.GeoDataFrame] | nx.Graph: + """ + Keep only the largest connected component of a graph. + + Identifies all connected components and retains only the largest one. + + Parameters + ---------- + graph : GeoDataFrame, tuple, or NetworkX graph + Input graph as edges GeoDataFrame, (nodes, edges) tuple, or NetworkX graph. + as_nx : bool, default False + If True, return as NetworkX graph. + + Returns + ------- + GeoDataFrame, tuple, or NetworkX graph + Graph with only largest component, in same format as input. + """ + nodes, edges, input_type = _validate_graph_input(graph) + + if edges.empty: + return _return_graph_output(nodes, edges, input_type, as_nx) + + # Build graph for component analysis + try: + nx_graph = gdf_to_nx(edges=edges) + except (ValueError, TypeError, KeyError): + return _return_graph_output(nodes, edges, input_type, as_nx) + + if nx_graph.number_of_nodes() == 0: + return _return_graph_output(nodes, edges, input_type, as_nx) + + # Find largest component + cc_func = nx.weakly_connected_components if nx_graph.is_directed() else nx.connected_components + largest_cc = max(cc_func(nx_graph), key=len) + subgraph = nx_graph.subgraph(largest_cc) + + # Get original edge indices + edge_indices = [ + d["_original_edge_index"] + for _, _, d in subgraph.edges(data=True) + if "_original_edge_index" in d + ] + filtered_edges = edges.loc[edge_indices] if edge_indices else edges.iloc[0:0] + + return _return_graph_output( + _filter_nodes_by_edges(nodes, filtered_edges), filtered_edges, input_type, as_nx + ) diff --git a/docs/api/data.md b/docs/api/data.md index 87b7d25..8802935 100644 --- a/docs/api/data.md +++ b/docs/api/data.md @@ -13,3 +13,4 @@ The data module provides functions for loading and processing geospatial data fr members: - load_overture_data - process_overture_segments + - get_boundaries diff --git a/docs/api/utils.md b/docs/api/utils.md index 5d8b403..3cfe282 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -22,3 +22,5 @@ The utils module provides core utility functions for graph conversion, validatio - create_tessellation - create_isochrone - plot_graph + - clip_graph + - remove_isolated_components diff --git a/pyproject.toml b/pyproject.toml index 5e4266a..8cef3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,10 @@ dependencies = [ "geopandas >=1.1.1", "libpysal >=4.12.1", "momepy", - "overturemaps>=0.17.0", + "overturemaps>=0.18.1", "rustworkx >=0.17.1", "scipy >=1.10.0", + "geopy >=2.4.0", ] [project.optional-dependencies] diff --git a/tests/test_data.py b/tests/test_data.py index 199c175..6067692 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,6 +1,5 @@ """Tests for the data module - refactored version focusing on public API only.""" -import importlib.util import subprocess from unittest.mock import Mock from unittest.mock import patch @@ -8,926 +7,1122 @@ import geopandas as gpd import pytest from shapely.geometry import LineString +from shapely.geometry import MultiLineString +from shapely.geometry import MultiPolygon from shapely.geometry import Point from shapely.geometry import Polygon +from city2graph import data as data_module from tests.helpers import make_connectors_gdf from tests.helpers import make_segments_gdf -spec = importlib.util.spec_from_file_location("data_module", "city2graph/data.py") -assert spec is not None -data_module = importlib.util.module_from_spec(spec) -assert spec.loader is not None -spec.loader.exec_module(data_module) VALID_OVERTURE_TYPES = data_module.VALID_OVERTURE_TYPES WGS84_CRS = data_module.WGS84_CRS load_overture_data = data_module.load_overture_data process_overture_segments = data_module.process_overture_segments +get_boundaries = data_module.get_boundaries + + +# ============================================================================ +# TESTS FOR CONSTANTS +# ============================================================================ + + +class TestConstants: + """Test module-level constants.""" + + def test_valid_overture_types_constant(self) -> None: + """Test that VALID_OVERTURE_TYPES contains expected types.""" + expected_types = { + "address", + "bathymetry", + "building", + "building_part", + "division", + "division_area", + "division_boundary", + "place", + "segment", + "connector", + "infrastructure", + "land", + "land_cover", + "land_use", + "water", + } + assert expected_types == VALID_OVERTURE_TYPES + + def test_wgs84_crs_constant(self) -> None: + """Test that WGS84_CRS is correctly defined.""" + assert WGS84_CRS == "EPSG:4326" + + +# ============================================================================ +# TESTS FOR load_overture_data FUNCTION +# ============================================================================ + + +class TestLoadOvertureData: + """Tests for the load_overture_data function.""" + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_with_bbox_list( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with bounding box as list.""" + types = ["building", "segment"] + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(test_bbox, types=types, output_dir="test_dir") + + assert len(result) == 2 + assert "building" in result + assert "segment" in result + mock_mkdir.assert_called_once() + assert mock_subprocess.call_count == 2 + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_with_polygon( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + test_polygon: Polygon, + ) -> None: + """Test load_overture_data with Polygon area.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(test_polygon, types=["building"]) + + assert "building" in result + mock_subprocess.assert_called_once() + mock_mkdir.assert_called() + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.gpd.clip") + @patch("city2graph.data.Path.exists") + def test_with_polygon_clipping( + self, + mock_exists: Mock, + mock_clip: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + test_polygon: Polygon, + ) -> None: + """Test that polygon areas are properly clipped.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_gdf.crs = "EPSG:3857" + mock_read_file.return_value = mock_gdf + mock_exists.return_value = True + mock_clipped_gdf = Mock(spec=gpd.GeoDataFrame) + mock_clip.return_value = mock_clipped_gdf + + result = load_overture_data(test_polygon, types=["building"]) + + mock_clip.assert_called_once() + mock_subprocess.assert_called() + assert result["building"] == mock_clipped_gdf + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_with_multipolygon( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + ) -> None: + """Test load_overture_data with MultiPolygon area.""" + poly1 = Polygon([(-74.01, 40.70), (-73.99, 40.70), (-73.99, 40.72), (-74.01, 40.72)]) + poly2 = Polygon([(-74.05, 40.75), (-74.03, 40.75), (-74.03, 40.77), (-74.05, 40.77)]) + test_multipolygon = MultiPolygon([poly1, poly2]) + + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(test_multipolygon, types=["building"]) + + assert "building" in result + mock_subprocess.assert_called_once() + mock_mkdir.assert_called() + + def test_invalid_types(self, test_bbox: list[float]) -> None: + """Test that invalid types raise ValueError.""" + invalid_types = ["building", "invalid_type", "another_invalid"] + + with pytest.raises( + ValueError, match=r"Invalid types: \['invalid_type', 'another_invalid'\]" + ): + load_overture_data(test_bbox, types=invalid_types) + + def test_invalid_release(self, test_bbox: list[float]) -> None: + """Test that invalid release raises ValueError.""" + invalid_release = "2099-99-99.0" + + with ( + patch.object( + data_module, "ALL_RELEASES", ["2025-08-20.0", "2025-08-20.1", "2025-09-24.0"] + ), + pytest.raises( + ValueError, + match="Invalid release: 2099-99-99.0. Valid releases are: 2025-08-20.0, 2025-08-20.1, 2025-09-24.0", + ), + ): + load_overture_data(test_bbox, types=["building"], release=invalid_release) + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_valid_release( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test that valid release is accepted without raising errors.""" + valid_release = "2025-08-20.1" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + with patch.object( + data_module, "ALL_RELEASES", ["2025-08-20.0", "2025-08-20.1", "2025-09-24.0"] + ): + result = load_overture_data(test_bbox, types=["building"], release=valid_release) + + assert "building" in result + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "-r" in call_args + assert valid_release in call_args + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_default_types( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test that all valid types are used when types=None.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(test_bbox, types=None, save_to_file=False) + + assert len(result) == len(VALID_OVERTURE_TYPES) + for data_type in VALID_OVERTURE_TYPES: + assert data_type in result + assert mock_subprocess.call_count == len(VALID_OVERTURE_TYPES) + + @patch("city2graph.data.subprocess.run") + def test_save_to_file_false( + self, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with save_to_file=False.""" + result = load_overture_data( + test_bbox, + types=["building"], + save_to_file=False, + return_data=False, + ) + + assert result == {} + mock_subprocess.assert_called_once() + args = mock_subprocess.call_args[0][0] + assert "-o" not in args + + @patch("city2graph.data.Path.exists") + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_segment_geometry_filtering( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + mock_exists: Mock, + test_bbox: list[float], + ) -> None: + """Test that non-LineString geometries are filtered from segments.""" + mock_exists.return_value = True + # Create mixed geometry segments + line = LineString([(0, 0), (1, 1)]) + point = Point(0.5, 0.5) + poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 0)]) + multi_line = MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) + + mock_gdf = gpd.GeoDataFrame( + { + "id": ["line", "point", "poly", "multi"], + "geometry": [line, point, poly, multi_line], + }, + crs=WGS84_CRS, + ) + mock_read_file.return_value = mock_gdf + + result = load_overture_data(test_bbox, types=["segment"]) + + mock_subprocess.assert_called() + assert "segment" in result + segments = result["segment"] + + # Should contain line and exploded multi-line (2 parts) -> total 3 + # Point and Polygon should be gone + assert len(segments) == 3 + assert all(isinstance(geom, LineString) for geom in segments.geometry) + assert "point" not in segments["id"].values + assert "poly" not in segments["id"].values + # Exploded multilinestring parts share the original ID + assert len(segments[segments["id"] == "multi"]) == 2 + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_return_data_false( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with return_data=False.""" + result = load_overture_data(test_bbox, types=["building"], return_data=False) + + assert result == {} + mock_read_file.assert_not_called() + mock_subprocess.assert_called() + + @patch("city2graph.data.subprocess.run") + def test_subprocess_error(self, mock_subprocess: Mock, test_bbox: list[float]) -> None: + """Test that subprocess errors are propagated.""" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "overturemaps") + + with pytest.raises(subprocess.CalledProcessError): + load_overture_data(test_bbox, types=["building"]) + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_prefix( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with filename prefix.""" + prefix = "test_prefix_" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + load_overture_data(test_bbox, types=["building"], prefix=prefix) + + args = mock_subprocess.call_args[0][0] + output_index = args.index("-o") + 1 + output_path = args[output_index] + assert prefix in output_path + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_connect_timeout( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with connect_timeout parameter.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + load_overture_data(test_bbox, types=["building"], connect_timeout=5.0) + + args = mock_subprocess.call_args[0][0] + assert "--connect-timeout" in args + assert "5.0" in args + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_request_timeout( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with request_timeout parameter.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + load_overture_data(test_bbox, types=["building"], request_timeout=10.0) + + args = mock_subprocess.call_args[0][0] + assert "--request-timeout" in args + assert "10.0" in args + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_no_stac( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with use_stac=False parameter.""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + load_overture_data(test_bbox, types=["building"], use_stac=False) + + args = mock_subprocess.call_args[0][0] + assert "--no-stac" in args + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_stac_true( + self, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test load_overture_data with use_stac=True (default).""" + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + load_overture_data(test_bbox, types=["building"], use_stac=True) + + args = mock_subprocess.call_args[0][0] + assert "--no-stac" not in args + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.exists") + def test_file_not_exists( + self, + mock_exists: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test behavior when output file doesn't exist.""" + mock_exists.return_value = False + + result = load_overture_data(test_bbox, types=["building"]) + + mock_read_file.assert_not_called() + assert "building" in result + mock_subprocess.assert_called() + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + def test_with_place_name( + self, + mock_read: Mock, + mock_subprocess: Mock, + ) -> None: + """Test load_overture_data with place_name parameter.""" + with patch.object(data_module, "Nominatim") as mock_nominatim: + mock_nominatim.return_value.geocode.return_value = [ + Mock( + raw={ + "geojson": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + }, + ) + ] + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read.return_value = mock_gdf + + result = load_overture_data(place_name="Liverpool", types=["building"]) + assert "building" in result + mock_subprocess.assert_called() + + def test_mutual_exclusion(self, test_bbox: list[float]) -> None: + """Test that area and place_name are mutually exclusive.""" + with pytest.raises(ValueError, match="Exactly one"): + load_overture_data(area=test_bbox, place_name="Liverpool") + with pytest.raises(ValueError, match="Exactly one"): + load_overture_data() + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_with_geodataframe_non_wgs84( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + ) -> None: + """Test load_overture_data with GeoDataFrame having non-WGS84 CRS.""" + _ = mock_mkdir + poly = Polygon([(-2.99, 53.40), (-2.98, 53.40), (-2.98, 53.41), (-2.99, 53.41)]) + area_gdf = gpd.GeoDataFrame(geometry=[poly], crs="EPSG:27700") + + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(area=area_gdf, types=["building"]) + + assert "building" in result + mock_subprocess.assert_called_once() + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_with_geoseries_non_wgs84( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + ) -> None: + """Test load_overture_data with GeoSeries having non-WGS84 CRS.""" + _ = mock_mkdir + poly = Polygon([(-2.99, 53.40), (-2.98, 53.40), (-2.98, 53.41), (-2.99, 53.41)]) + area_series = gpd.GeoSeries([poly], crs="EPSG:27700") + + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + result = load_overture_data(area=area_series, types=["building"]) -# Tests for constants and basic functionality -def test_valid_overture_types_constant() -> None: - """Test that VALID_OVERTURE_TYPES contains expected types.""" - expected_types = { - "address", - "bathymetry", - "building", - "building_part", - "division", - "division_area", - "division_boundary", - "place", - "segment", - "connector", - "infrastructure", - "land", - "land_cover", - "land_use", - "water", - } - assert expected_types == VALID_OVERTURE_TYPES - - -def test_wgs84_crs_constant() -> None: - """Test that WGS84_CRS is correctly defined.""" - assert WGS84_CRS == "EPSG:4326" - - -# Tests for load_overture_data function -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.Path.mkdir") -def test_load_overture_data_with_bbox_list( - mock_mkdir: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with bounding box as list.""" - # Setup - types = ["building", "segment"] - - # Mock GeoDataFrame - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - # Execute - result = load_overture_data(test_bbox, types=types, output_dir="test_dir") - - # Verify - assert len(result) == 2 - assert "building" in result - assert "segment" in result - mock_mkdir.assert_called_once() - assert mock_subprocess.call_count == 2 - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.Path.mkdir") -def test_load_overture_data_with_polygon( - mock_mkdir: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, - test_polygon: Polygon, -) -> None: - """Test load_overture_data with Polygon area.""" - # Setup - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - # Execute - result = load_overture_data(test_polygon, types=["building"]) - - # Verify - assert "building" in result - mock_subprocess.assert_called_once() - mock_mkdir.assert_called() # Verify directory creation - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.gpd.clip") -@patch("city2graph.data.Path.exists") -def test_load_overture_data_with_polygon_clipping( - mock_exists: Mock, - mock_clip: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, - test_polygon: Polygon, -) -> None: - """Test that polygon areas are properly clipped.""" - # Setup - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_gdf.crs = "EPSG:3857" - mock_read_file.return_value = mock_gdf - mock_exists.return_value = True - - mock_clipped_gdf = Mock(spec=gpd.GeoDataFrame) - mock_clip.return_value = mock_clipped_gdf - - # Execute - result = load_overture_data(test_polygon, types=["building"]) - - # Verify - mock_clip.assert_called_once() - mock_subprocess.assert_called() # Verify subprocess was called - assert result["building"] == mock_clipped_gdf - - -def test_load_overture_data_invalid_types(test_bbox: list[float]) -> None: - """Test that invalid types raise ValueError.""" - invalid_types = ["building", "invalid_type", "another_invalid"] - - with pytest.raises(ValueError, match="Invalid types: \\['invalid_type', 'another_invalid'\\]"): - load_overture_data(test_bbox, types=invalid_types) - - -def test_load_overture_data_invalid_release(test_bbox: list[float]) -> None: - """Test that invalid release raises ValueError.""" - invalid_release = "2099-99-99.0" - - # Patch ALL_RELEASES at the data module level - with ( - patch.object(data_module, "ALL_RELEASES", ["2025-08-20.0", "2025-08-20.1", "2025-09-24.0"]), - pytest.raises( - ValueError, - match="Invalid release: 2099-99-99.0. Valid releases are: 2025-08-20.0, 2025-08-20.1, 2025-09-24.0", - ), - ): - load_overture_data(test_bbox, types=["building"], release=invalid_release) - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_valid_release( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test that valid release is accepted without raising errors.""" - valid_release = "2025-08-20.1" - - # Mock GeoDataFrame - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - # Patch ALL_RELEASES at the data module level - with patch.object( - data_module, "ALL_RELEASES", ["2025-08-20.0", "2025-08-20.1", "2025-09-24.0"] - ): - result = load_overture_data(test_bbox, types=["building"], release=valid_release) - - # Verify the function completed successfully assert "building" in result - # Verify the release parameter was passed to subprocess mock_subprocess.assert_called_once() - call_args = mock_subprocess.call_args[0][0] - assert "-r" in call_args - assert valid_release in call_args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_default_types( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test that all valid types are used when types=None.""" - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - result = load_overture_data(test_bbox, types=None, save_to_file=False) - - # Should have all valid types - assert len(result) == len(VALID_OVERTURE_TYPES) - for data_type in VALID_OVERTURE_TYPES: - assert data_type in result - - # Verify subprocess was called for each type - assert mock_subprocess.call_count == len(VALID_OVERTURE_TYPES) - - -@patch("city2graph.data.subprocess.run") -def test_load_overture_data_save_to_file_false( - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with save_to_file=False.""" - result = load_overture_data( - test_bbox, - types=["building"], - save_to_file=False, - return_data=False, - ) - - # Should return empty dict when return_data=False - assert result == {} - - # Subprocess should be called without output file - mock_subprocess.assert_called_once() - args = mock_subprocess.call_args[0][0] - assert "-o" not in args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_return_data_false( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with return_data=False.""" - result = load_overture_data(test_bbox, types=["building"], return_data=False) - - assert result == {} - mock_read_file.assert_not_called() - mock_subprocess.assert_called() # Should still call subprocess to generate files - - -@patch("city2graph.data.subprocess.run") -def test_load_overture_data_subprocess_error(mock_subprocess: Mock, test_bbox: list[float]) -> None: - """Test that subprocess errors are propagated.""" - mock_subprocess.side_effect = subprocess.CalledProcessError(1, "overturemaps") - - with pytest.raises(subprocess.CalledProcessError): - load_overture_data(test_bbox, types=["building"]) - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_with_prefix( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with filename prefix.""" - prefix = "test_prefix_" - - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - load_overture_data(test_bbox, types=["building"], prefix=prefix) - - # Check that the output path includes the prefix - args = mock_subprocess.call_args[0][0] - output_index = args.index("-o") + 1 - output_path = args[output_index] - assert prefix in output_path - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_with_connect_timeout( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with connect_timeout parameter.""" - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - load_overture_data(test_bbox, types=["building"], connect_timeout=5.0) - - # Verify connect_timeout parameter was passed to subprocess - args = mock_subprocess.call_args[0][0] - assert "--connect-timeout" in args - assert "5.0" in args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_with_request_timeout( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with request_timeout parameter.""" - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - load_overture_data(test_bbox, types=["building"], request_timeout=10.0) - - # Verify request_timeout parameter was passed to subprocess - args = mock_subprocess.call_args[0][0] - assert "--request-timeout" in args - assert "10.0" in args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_with_no_stac( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with use_stac=False parameter.""" - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - load_overture_data(test_bbox, types=["building"], use_stac=False) - - # Verify --no-stac flag was passed to subprocess - args = mock_subprocess.call_args[0][0] - assert "--no-stac" in args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -def test_load_overture_data_with_stac_true( - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test load_overture_data with use_stac=True (default).""" - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - load_overture_data(test_bbox, types=["building"], use_stac=True) - - # Verify --no-stac flag was NOT passed to subprocess - args = mock_subprocess.call_args[0][0] - assert "--no-stac" not in args - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.Path.exists") -def test_load_overture_data_file_not_exists( - mock_exists: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test behavior when output file doesn't exist.""" - mock_exists.return_value = False - - result = load_overture_data(test_bbox, types=["building"]) - - # Should return empty GeoDataFrame when file doesn't exist - mock_read_file.assert_not_called() - assert "building" in result - mock_subprocess.assert_called() # Should still attempt to generate files - - -# Tests for process_overture_segments function -def test_process_overture_segments_empty_input() -> None: - """Test process_overture_segments with empty GeoDataFrame.""" - empty_gdf = gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) - result = process_overture_segments(empty_gdf) - - assert result.empty - assert result.crs == WGS84_CRS - - -def test_process_overture_segments_basic() -> None: - """Test basic functionality of process_overture_segments with local data.""" - # Create a minimal segments GeoDataFrame locally to avoid fixture dependency - segments_gdf = make_segments_gdf( - ids=["s1", "s2"], - geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], - level_rules="", - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=False) - - # Should have length column - assert "length" in result.columns - assert all(result["length"] > 0) - - # Should preserve original data - assert len(result) >= len(segments_gdf) - assert "id" in result.columns - - -def test_process_overture_segments_with_connectors() -> None: - """Test process_overture_segments with connectors.""" - connectors_data = '[{"connector_id": "c1", "at": 0.25}, {"connector_id": "c2", "at": 0.75}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (4, 0)]], - connectors=connectors_data, - level_rules="", - crs=WGS84_CRS, - ) - connectors_gdf = make_connectors_gdf(ids=["c1", "c2"], coords=[(1, 0), (3, 0)], crs=WGS84_CRS) - - result = process_overture_segments( - segments_gdf, - connectors_gdf=connectors_gdf, - get_barriers=False, - ) - - # Should have split columns for segments that were split - split_segments = result[result["id"].str.contains("_")] - if not split_segments.empty: - assert "split_from" in result.columns - assert "split_to" in result.columns - - -def test_process_overture_segments_with_barriers() -> None: - """Test process_overture_segments with barrier generation.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules="", - crs=WGS84_CRS, - ) - result = process_overture_segments(segments_gdf, get_barriers=True) - - # Should have barrier_geometry column - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_missing_level_rules() -> None: - """Test process_overture_segments with missing level_rules column.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=None, - crs=WGS84_CRS, - ) - - # This should work - the function should handle missing level_rules gracefully - result = process_overture_segments(segments_gdf) - assert "level_rules" in result.columns - assert result["level_rules"].iloc[0] == "" - - -def test_process_overture_segments_with_threshold() -> None: - """Test process_overture_segments with custom threshold.""" - connectors_data = '[{"connector_id": "c1", "at": 0.5}]' - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[[(0, 0), (1, 1)], [(1.1, 1.1), (2, 2)]], - connectors=[connectors_data, connectors_data], - level_rules="", - crs=WGS84_CRS, - ) - connectors_gdf = make_connectors_gdf(ids=["c1"], coords=[(1, 1)], crs=WGS84_CRS) - - result = process_overture_segments( - segments_gdf, - connectors_gdf=connectors_gdf, - threshold=2.0, - ) - - # Should process without errors - assert "length" in result.columns - - -def test_process_overture_segments_no_connectors() -> None: - """Test process_overture_segments with None connectors.""" - segments_gdf = make_segments_gdf( - ids=["s1", "s2"], - geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], - level_rules="", - crs=WGS84_CRS, - ) - result = process_overture_segments(segments_gdf, connectors_gdf=None) - - # Should not perform endpoint clustering - assert len(result) == len(segments_gdf) - - -def test_process_overture_segments_empty_connectors() -> None: - """Test process_overture_segments with empty connectors GeoDataFrame.""" - segments_gdf = make_segments_gdf( - ids=["s1", "s2"], - geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], - level_rules="", - crs=WGS84_CRS, - ) - empty_connectors = make_connectors_gdf(ids=[], coords=[], crs=WGS84_CRS) - result = process_overture_segments(segments_gdf, connectors_gdf=empty_connectors) - - # Should not perform splitting or clustering - assert len(result) == len(segments_gdf) - - -def test_process_overture_segments_invalid_connector_data() -> None: - """Test process_overture_segments with invalid connector JSON.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - connectors="invalid_json", - level_rules="", - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf) - - # Should handle invalid JSON gracefully - assert len(result) == 1 - - -def test_process_overture_segments_malformed_connectors() -> None: - """Test process_overture_segments with malformed connector data.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - connectors='{"invalid": "structure"}', - level_rules="", - crs=WGS84_CRS, - ) - connectors_gdf = make_connectors_gdf(ids=["x"], coords=[(0.5, 0.5)], crs=WGS84_CRS) - result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) - - # Should handle malformed data gracefully - assert len(result) == 1 - - -def test_process_overture_segments_invalid_level_rules() -> None: - """Test process_overture_segments with invalid level rules JSON.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules="invalid_json", - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - - # Should handle invalid JSON gracefully - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_complex_level_rules() -> None: - """Test process_overture_segments with complex level rules.""" - level_rules = '[{"value": 1, "between": [0.1, 0.3]}, {"value": 1, "between": [0.7, 0.9]}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - - # Should create barrier geometry - assert "barrier_geometry" in result.columns - assert result["barrier_geometry"].iloc[0] is not None - - -def test_process_overture_segments_full_barrier() -> None: - """Test process_overture_segments with full barrier level rules.""" - level_rules = '[{"value": 1}]' # No "between" means full barrier - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - - # Should create None barrier geometry for full barriers - assert "barrier_geometry" in result.columns - assert result["barrier_geometry"].iloc[0] is None - - -def test_process_overture_segments_zero_value_rules() -> None: - """Test process_overture_segments with zero value level rules.""" - level_rules = '[{"value": 0, "between": [0.2, 0.8]}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - - # Zero value rules should be ignored - assert "barrier_geometry" in result.columns - # Should return original geometry since no barriers - barrier_geom = result["barrier_geometry"].iloc[0] - assert barrier_geom is not None - - -def test_process_overture_segments_segment_splitting() -> None: - """Test that segments are properly split at connector positions.""" - connectors_data = ( - '[{"connector_id": "conn1", "at": 0.0}, ' - '{"connector_id": "conn2", "at": 0.5}, ' - '{"connector_id": "conn3", "at": 1.0}]' - ) - - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (2, 2)]], - connectors=connectors_data, - level_rules="", - crs=WGS84_CRS, - ) - - connectors_gdf = make_connectors_gdf( - ids=["conn1", "conn2", "conn3"], - coords=[(0, 0), (1, 1), (2, 2)], - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) - - # Should create multiple segments - assert len(result) > 1 - # Should have split information - split_segments = result[result["id"].str.contains("_")] - assert not split_segments.empty - - -def test_process_overture_segments_endpoint_clustering() -> None: - """Test endpoint clustering functionality.""" - # Create segments with nearby endpoints - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[[(0, 0), (1, 1)], [(1.1, 1.1), (2, 2)]], - level_rules="", - crs=WGS84_CRS, - ) - - connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) - - result = process_overture_segments( - segments_gdf, - connectors_gdf=connectors_gdf, - threshold=0.5, # Large enough to cluster nearby points - ) - - # Should process without errors - assert len(result) >= len(segments_gdf) - - -def test_process_overture_segments_level_rules_handling() -> None: - """Test process_overture_segments level_rules column handling.""" - # Test with None values in level_rules - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=[None], - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf) - assert result["level_rules"].iloc[0] == "" - - -# Integration tests -def test_load_and_process_integration() -> None: - """Test integration between load_overture_data and process_overture_segments.""" - # Create mock data that resembles real Overture data - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[[(0, 0), (1, 1)], [(1, 1), (2, 2)]], - connectors=[ - '[{"connector_id": "conn1", "at": 0.0}]', - '[{"connector_id": "conn2", "at": 1.0}]', - ], - level_rules="", - crs=WGS84_CRS, - ) - - # Process the segments - result = process_overture_segments(segments_gdf) - - # Should have all expected columns - expected_columns = ["id", "connectors", "level_rules", "geometry", "length", "barrier_geometry"] - for col in expected_columns: - assert col in result.columns - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.Path.exists") -def test_real_world_scenario_simulation( - mock_exists: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, - test_bbox: list[float], -) -> None: - """Test a scenario that simulates real-world usage without external fixtures.""" - # Build local realistic-like GeoDataFrames - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[[(0, 0), (1, 1)], [(1, 1), (2, 2)]], - connectors=[ - '[{"connector_id": "conn1", "at": 0.25}]', - '[{"connector_id": "conn2", "at": 0.75}]', - ], - level_rules="", - crs=WGS84_CRS, - ) - connectors_gdf = make_connectors_gdf( - ids=["conn1", "conn2"], - coords=[(0.25, 0.25), (1.75, 1.75)], - crs=WGS84_CRS, - ) - - # Mock the file reading to return our local data - mock_read_file.side_effect = [segments_gdf, connectors_gdf] - mock_exists.return_value = True - - # Simulate loading data - data = load_overture_data(test_bbox, types=["segment", "connector"]) - - # Process the segments - processed_segments = process_overture_segments( - data["segment"], - connectors_gdf=data["connector"], - ) - assert not processed_segments.empty - assert "barrier_geometry" in processed_segments.columns - - # Verify mocks were called appropriately - mock_subprocess.assert_called() - assert "length" in processed_segments.columns - - -# Additional edge case tests for comprehensive coverage -def test_process_overture_segments_with_non_dict_connector() -> None: - """Test process_overture_segments with non-dict connector data.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - connectors='["not_a_dict"]', - level_rules="", - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf) - # Should handle non-dict data gracefully - assert len(result) == 1 - - -def test_process_overture_segments_with_non_dict_level_rule() -> None: - """Test process_overture_segments with non-dict level rule data.""" - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules='["not_a_dict"]', - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should handle non-dict data gracefully - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_with_short_between_array() -> None: - """Test process_overture_segments with short between array in level rules.""" - level_rules = '[{"value": 1, "between": [0.5]}]' # Only one element - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (1, 1)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should handle short between array gracefully - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_with_empty_geometry() -> None: - """Test process_overture_segments with empty geometry.""" - # Create an empty LineString - empty_geom = LineString() - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[empty_geom], - level_rules="", - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should handle empty geometry gracefully - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_with_overlapping_barriers() -> None: - """Test process_overture_segments with overlapping barrier intervals.""" - level_rules = '[{"value": 1, "between": [0.1, 0.5]}, {"value": 1, "between": [0.3, 0.7]}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (4, 4)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should handle overlapping barriers correctly - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_with_touching_barriers() -> None: - """Test process_overture_segments with touching barrier intervals.""" - level_rules = '[{"value": 1, "between": [0.0, 0.3]}, {"value": 1, "between": [0.3, 0.6]}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (4, 4)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should handle touching barriers correctly - assert "barrier_geometry" in result.columns - - -def test_process_overture_segments_with_full_coverage_barriers() -> None: - """Test process_overture_segments with barriers covering full segment.""" - level_rules = '[{"value": 1, "between": [0.0, 1.0]}]' - segments_gdf = make_segments_gdf( - ids=["seg1"], - geoms_or_coords=[[(0, 0), (4, 4)]], - level_rules=level_rules, - crs=WGS84_CRS, - ) - - result = process_overture_segments(segments_gdf, get_barriers=True) - # Should return None for full coverage barriers - assert "barrier_geometry" in result.columns - assert result["barrier_geometry"].iloc[0] is None - - -@patch("city2graph.data.subprocess.run") -@patch("city2graph.data.gpd.read_file") -@patch("city2graph.data.Path.mkdir") -def test_load_overture_data_comprehensive_all_types( - mock_mkdir: Mock, - mock_read_file: Mock, - mock_subprocess: Mock, -) -> None: - """Test load_overture_data with all types (types=None).""" - # mock_mkdir is set up by @patch decorator but not called in this test with save_to_file=False - _ = mock_mkdir # Acknowledge the parameter - - # Mock GeoDataFrame - mock_gdf = Mock(spec=gpd.GeoDataFrame) - mock_gdf.empty = False - mock_read_file.return_value = mock_gdf - - # Test with all types (types=None) - bbox = [-74.01, 40.70, -73.99, 40.72] - result = load_overture_data(bbox, types=None, save_to_file=False) - - # Should call subprocess for all valid types - assert mock_subprocess.call_count == len(VALID_OVERTURE_TYPES) - assert len(result) == len(VALID_OVERTURE_TYPES) - - -def test_process_overture_segments_with_non_linestring_endpoints() -> None: - """Test endpoint clustering with non-LineString geometries.""" - # Mix LineString and Point geometries - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[LineString([(0, 0), (1, 1)]), Point(2, 2)], - level_rules="", - crs=WGS84_CRS, - ) - - connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) - - result = process_overture_segments( - segments_gdf, - connectors_gdf=connectors_gdf, - threshold=1.0, - ) - - # Should process without errors - assert len(result) == len(segments_gdf) - - -def test_process_overture_segments_with_short_linestring() -> None: - """Test endpoint clustering with LineString having insufficient coordinates.""" - # Create a degenerate LineString (same start and end point) - invalid_geom = LineString([(0, 0), (0, 0)]) # Degenerate but valid - segments_gdf = make_segments_gdf( - ids=["seg1", "seg2"], - geoms_or_coords=[LineString([(0, 0), (1, 1)]), invalid_geom], - level_rules="", - crs=WGS84_CRS, - ) - - connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) - - result = process_overture_segments( - segments_gdf, - connectors_gdf=connectors_gdf, - threshold=1.0, - ) - - # Should process without errors - assert len(result) == len(segments_gdf) + + @patch("city2graph.data.subprocess.run") + def test_stdout_json_parsing(self, mock_subprocess: Mock) -> None: + """Test JSON parsing from subprocess stdout with prefix text.""" + geojson_str = '{"type": "FeatureCollection", "features": []}' + mock_subprocess.return_value = Mock( + stdout=f"Some warning message\n{geojson_str}", + returncode=0, + ) + + bbox = [-74.01, 40.70, -73.99, 40.72] + result = load_overture_data(bbox, types=["building"], save_to_file=False) + + assert "building" in result + mock_subprocess.assert_called() + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.mkdir") + def test_comprehensive_all_types( + self, + mock_mkdir: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + ) -> None: + """Test load_overture_data with all types (types=None).""" + _ = mock_mkdir + mock_gdf = Mock(spec=gpd.GeoDataFrame) + mock_gdf.empty = False + mock_read_file.return_value = mock_gdf + + bbox = [-74.01, 40.70, -73.99, 40.72] + result = load_overture_data(bbox, types=None, save_to_file=False) + + assert mock_subprocess.call_count == len(VALID_OVERTURE_TYPES) + assert len(result) == len(VALID_OVERTURE_TYPES) + + +# ============================================================================ +# TESTS FOR process_overture_segments FUNCTION +# ============================================================================ + + +class TestProcessOvertureSegments: + """Tests for the process_overture_segments function.""" + + def test_empty_input(self) -> None: + """Test process_overture_segments with empty GeoDataFrame.""" + empty_gdf = gpd.GeoDataFrame(geometry=[], crs=WGS84_CRS) + result = process_overture_segments(empty_gdf) + + assert result.empty + assert result.crs == WGS84_CRS + + def test_basic(self) -> None: + """Test basic functionality of process_overture_segments.""" + segments_gdf = make_segments_gdf( + ids=["s1", "s2"], + geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], + level_rules="", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=False) + + assert "length" in result.columns + assert all(result["length"] > 0) + assert len(result) >= len(segments_gdf) + assert "id" in result.columns + + def test_with_connectors(self) -> None: + """Test process_overture_segments with connectors.""" + connectors_data = '[{"connector_id": "c1", "at": 0.25}, {"connector_id": "c2", "at": 0.75}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (4, 0)]], + connectors=connectors_data, + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf( + ids=["c1", "c2"], coords=[(1, 0), (3, 0)], crs=WGS84_CRS + ) + + result = process_overture_segments( + segments_gdf, + connectors_gdf=connectors_gdf, + get_barriers=False, + ) + + split_segments = result[result["id"].str.contains("_")] + if not split_segments.empty: + assert "split_from" in result.columns + assert "split_to" in result.columns + + def test_with_barriers(self) -> None: + """Test process_overture_segments with barrier generation.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules="", + crs=WGS84_CRS, + ) + result = process_overture_segments(segments_gdf, get_barriers=True) + + assert "barrier_geometry" in result.columns + + def test_missing_level_rules(self) -> None: + """Test process_overture_segments with missing level_rules column.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=None, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf) + assert "level_rules" in result.columns + assert result["level_rules"].iloc[0] == "" + + def test_with_threshold(self) -> None: + """Test process_overture_segments with custom threshold.""" + connectors_data = '[{"connector_id": "c1", "at": 0.5}]' + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[[(0, 0), (1, 1)], [(1.1, 1.1), (2, 2)]], + connectors=[connectors_data, connectors_data], + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf(ids=["c1"], coords=[(1, 1)], crs=WGS84_CRS) + + result = process_overture_segments( + segments_gdf, + connectors_gdf=connectors_gdf, + threshold=2.0, + ) + + assert "length" in result.columns + + def test_no_connectors(self) -> None: + """Test process_overture_segments with None connectors.""" + segments_gdf = make_segments_gdf( + ids=["s1", "s2"], + geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], + level_rules="", + crs=WGS84_CRS, + ) + result = process_overture_segments(segments_gdf, connectors_gdf=None) + + assert len(result) == len(segments_gdf) + + def test_empty_connectors(self) -> None: + """Test process_overture_segments with empty connectors GeoDataFrame.""" + segments_gdf = make_segments_gdf( + ids=["s1", "s2"], + geoms_or_coords=[[(0, 0), (1, 0)], [(1, 0), (2, 0)]], + level_rules="", + crs=WGS84_CRS, + ) + empty_connectors = make_connectors_gdf(ids=[], coords=[], crs=WGS84_CRS) + result = process_overture_segments(segments_gdf, connectors_gdf=empty_connectors) + + assert len(result) == len(segments_gdf) + + def test_invalid_connector_data(self) -> None: + """Test process_overture_segments with invalid connector JSON.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + connectors="invalid_json", + level_rules="", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf) + assert len(result) == 1 + + def test_malformed_connectors(self) -> None: + """Test process_overture_segments with malformed connector data.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + connectors='{"invalid": "structure"}', + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf(ids=["x"], coords=[(0.5, 0.5)], crs=WGS84_CRS) + result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) + + assert len(result) == 1 + + def test_invalid_level_rules(self) -> None: + """Test process_overture_segments with invalid level rules JSON.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules="invalid_json", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_complex_level_rules(self) -> None: + """Test process_overture_segments with complex level rules.""" + level_rules = '[{"value": 1, "between": [0.1, 0.3]}, {"value": 1, "between": [0.7, 0.9]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + + assert "barrier_geometry" in result.columns + assert result["barrier_geometry"].iloc[0] is not None + + def test_full_barrier(self) -> None: + """Test process_overture_segments with full barrier level rules.""" + level_rules = '[{"value": 1}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + + assert "barrier_geometry" in result.columns + assert result["barrier_geometry"].iloc[0] is None + + def test_zero_value_rules(self) -> None: + """Test process_overture_segments with zero value level rules.""" + level_rules = '[{"value": 0, "between": [0.2, 0.8]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + + assert "barrier_geometry" in result.columns + barrier_geom = result["barrier_geometry"].iloc[0] + assert barrier_geom is not None + + def test_segment_splitting(self) -> None: + """Test that segments are properly split at connector positions.""" + connectors_data = ( + '[{"connector_id": "conn1", "at": 0.0}, ' + '{"connector_id": "conn2", "at": 0.5}, ' + '{"connector_id": "conn3", "at": 1.0}]' + ) + + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (2, 2)]], + connectors=connectors_data, + level_rules="", + crs=WGS84_CRS, + ) + + connectors_gdf = make_connectors_gdf( + ids=["conn1", "conn2", "conn3"], + coords=[(0, 0), (1, 1), (2, 2)], + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) + + assert len(result) > 1 + split_segments = result[result["id"].str.contains("_")] + assert not split_segments.empty + + def test_endpoint_clustering(self) -> None: + """Test endpoint clustering functionality.""" + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[[(0, 0), (1, 1)], [(1.1, 1.1), (2, 2)]], + level_rules="", + crs=WGS84_CRS, + ) + + connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) + + result = process_overture_segments( + segments_gdf, + connectors_gdf=connectors_gdf, + threshold=0.5, + ) + + assert len(result) >= len(segments_gdf) + + def test_level_rules_handling(self) -> None: + """Test process_overture_segments level_rules column handling.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=[None], + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf) + assert result["level_rules"].iloc[0] == "" + + def test_with_non_dict_connector(self) -> None: + """Test process_overture_segments with non-dict connector data.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + connectors='["not_a_dict"]', + level_rules="", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf) + assert len(result) == 1 + + def test_with_non_dict_level_rule(self) -> None: + """Test process_overture_segments with non-dict level rule data.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules='["not_a_dict"]', + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_with_short_between_array(self) -> None: + """Test process_overture_segments with short between array in level rules.""" + level_rules = '[{"value": 1, "between": [0.5]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_with_empty_geometry(self) -> None: + """Test process_overture_segments with empty geometry.""" + empty_geom = LineString() + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[empty_geom], + level_rules="", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_with_overlapping_barriers(self) -> None: + """Test process_overture_segments with overlapping barrier intervals.""" + level_rules = '[{"value": 1, "between": [0.1, 0.5]}, {"value": 1, "between": [0.3, 0.7]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (4, 4)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_with_touching_barriers(self) -> None: + """Test process_overture_segments with touching barrier intervals.""" + level_rules = '[{"value": 1, "between": [0.0, 0.3]}, {"value": 1, "between": [0.3, 0.6]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (4, 4)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + + def test_with_full_coverage_barriers(self) -> None: + """Test process_overture_segments with barriers covering full segment.""" + level_rules = '[{"value": 1, "between": [0.0, 1.0]}]' + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (4, 4)]], + level_rules=level_rules, + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + assert "barrier_geometry" in result.columns + assert result["barrier_geometry"].iloc[0] is None + + def test_with_non_linestring_endpoints(self) -> None: + """Test endpoint clustering with non-LineString geometries.""" + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[LineString([(0, 0), (1, 1)]), Point(2, 2)], + level_rules="", + crs=WGS84_CRS, + ) + + connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) + + result = process_overture_segments( + segments_gdf, + connectors_gdf=connectors_gdf, + threshold=1.0, + ) + + # Point geometry should NOT be filtered out (filtering moved to load_overture_data) + assert len(result) == 2 + assert result.iloc[0]["id"] == "seg1" + + def test_with_short_linestring(self) -> None: + """Test endpoint clustering with LineString having insufficient coordinates.""" + invalid_geom = LineString([(0, 0), (0, 0)]) + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[LineString([(0, 0), (1, 1)]), invalid_geom], + level_rules="", + crs=WGS84_CRS, + ) + + connectors_gdf = make_connectors_gdf(ids=["conn1"], coords=[(1, 1)], crs=WGS84_CRS) + + result = process_overture_segments( + segments_gdf, + connectors_gdf=connectors_gdf, + threshold=1.0, + ) + + assert len(result) == len(segments_gdf) + + def test_connectors_as_list(self) -> None: + """Test process_overture_segments when connectors column is already a list.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (2, 0)]], + connectors='[{"connector_id": "c1", "at": 0.5}]', + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf(ids=["c1"], coords=[(1, 0)], crs=WGS84_CRS) + + result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) + + assert len(result) >= 1 + + def test_connectors_json_decode_error(self) -> None: + """Test process_overture_segments with JSON that causes decode error.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (1, 1)]], + connectors="{malformed json without quotes}", + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf(ids=["c1"], coords=[(0.5, 0.5)], crs=WGS84_CRS) + + result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) + + assert len(result) == 1 + + def test_level_rules_as_list(self) -> None: + """Test process_overture_segments when level_rules is already a list.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (4, 4)]], + level_rules='[{"value": 1, "between": [0.1, 0.5]}]', + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf, get_barriers=True) + + assert "barrier_geometry" in result.columns + + def test_connectors_single_dict(self) -> None: + """Test process_overture_segments when connectors parses to a single dict.""" + segments_gdf = make_segments_gdf( + ids=["seg1"], + geoms_or_coords=[[(0, 0), (2, 0)]], + connectors='{"connector_id": "c1", "at": 0.5}', + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf(ids=["c1"], coords=[(1, 0)], crs=WGS84_CRS) + + result = process_overture_segments(segments_gdf, connectors_gdf=connectors_gdf) + + assert len(result) >= 1 + + +# ============================================================================ +# TESTS FOR get_boundaries FUNCTION +# ============================================================================ + + +class TestGetBoundaries: + """Tests for the get_boundaries function.""" + + def test_polygon(self) -> None: + """Test polygon boundary retrieval.""" + with patch.object(data_module, "Nominatim") as mock_nominatim: + mock_nominatim.return_value.geocode.return_value = [ + Mock( + raw={ + "geojson": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + }, + ) + ] + result = get_boundaries("Liverpool") + assert result.geometry.geom_type.iloc[0] == "Polygon" + assert result.crs == WGS84_CRS + + def test_not_found(self) -> None: + """Test error when place not found.""" + with patch.object(data_module, "Nominatim") as mock_nominatim: + mock_nominatim.return_value.geocode.return_value = [] + with pytest.raises(ValueError, match="Place not found"): + get_boundaries("NonexistentPlace12345") + + def test_point_error(self) -> None: + """Test error when Point geometry returned.""" + with patch.object(data_module, "Nominatim") as mock_nominatim: + mock_nominatim.return_value.geocode.return_value = [ + Mock( + raw={"geojson": {"type": "Point", "coordinates": [0, 0]}}, + ) + ] + with pytest.raises(ValueError, match="No polygon boundary"): + get_boundaries("123 Main St") + + +# ============================================================================ +# INTEGRATION TESTS +# ============================================================================ + + +class TestIntegration: + """Integration tests for the data module.""" + + def test_load_and_process_integration(self) -> None: + """Test integration between load_overture_data and process_overture_segments.""" + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[[(0, 0), (1, 1)], [(1, 1), (2, 2)]], + connectors=[ + '[{"connector_id": "conn1", "at": 0.0}]', + '[{"connector_id": "conn2", "at": 1.0}]', + ], + level_rules="", + crs=WGS84_CRS, + ) + + result = process_overture_segments(segments_gdf) + + expected_columns = [ + "id", + "connectors", + "level_rules", + "geometry", + "length", + "barrier_geometry", + ] + for col in expected_columns: + assert col in result.columns + + @patch("city2graph.data.subprocess.run") + @patch("city2graph.data.gpd.read_file") + @patch("city2graph.data.Path.exists") + def test_real_world_scenario_simulation( + self, + mock_exists: Mock, + mock_read_file: Mock, + mock_subprocess: Mock, + test_bbox: list[float], + ) -> None: + """Test a scenario that simulates real-world usage.""" + segments_gdf = make_segments_gdf( + ids=["seg1", "seg2"], + geoms_or_coords=[[(0, 0), (1, 1)], [(1, 1), (2, 2)]], + connectors=[ + '[{"connector_id": "conn1", "at": 0.25}]', + '[{"connector_id": "conn2", "at": 0.75}]', + ], + level_rules="", + crs=WGS84_CRS, + ) + connectors_gdf = make_connectors_gdf( + ids=["conn1", "conn2"], + coords=[(0.25, 0.25), (1.75, 1.75)], + crs=WGS84_CRS, + ) + + mock_read_file.side_effect = [segments_gdf, connectors_gdf] + mock_exists.return_value = True + + data = load_overture_data(test_bbox, types=["segment", "connector"]) + + processed_segments = process_overture_segments( + data["segment"], + connectors_gdf=data["connector"], + ) + assert not processed_segments.empty + assert "barrier_geometry" in processed_segments.columns + + mock_subprocess.assert_called() + assert "length" in processed_segments.columns diff --git a/tests/test_utils.py b/tests/test_utils.py index 9be0749..70a0b00 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,6 +17,7 @@ import pytest import rustworkx as rx from shapely.geometry import LineString +from shapely.geometry import MultiLineString from shapely.geometry import Point from shapely.geometry import Polygon @@ -2337,3 +2338,315 @@ def test_standard_index_name_matching(self) -> None: result = utils._get_col_or_level(df, "my_index") assert result is not None assert (result == [10, 20, 30]).all() + + +# ============================================================================ +# UTILITIES TESTS +# ============================================================================ + + +class TestClipGraph(BaseGraphTest): + """Test graph clipping functionality.""" + + def test_clip_graph_basic(self, sample_crs: str) -> None: + """Test basic clipping with a polygon (default: strict within).""" + clip_poly = Polygon([(0, 0), (1, 0), (1, 2), (0, 2)]) + + gdf = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(0, 0), (2, 0)]), # partially inside + LineString([(0.5, 0.5), (0.5, 1.5)]), # fully inside + LineString([(2, 0), (3, 0)]), # fully outside + ] + }, + crs=sample_crs, + ) + + # Default behavior: geometric clipping + clipped = utils.clip_graph(gdf, clip_poly) + assert isinstance(clipped, gpd.GeoDataFrame) + assert len(clipped) == 2 # Now keeps both fully inside and partially inside (clipped) + + # Check that the partially inside line was clipped + _ = clipped[clipped.geometry.length < 1.1] # The one that was clipped (length 1.0) + # Note: Depending on order/index, we need to be careful. + # Original: LineString([(0, 0), (2, 0)]) -> clipped to [(0,0), (1,0)] + + # Verify geometries + # 1. [(0.5, 0.5), (0.5, 1.5)] - Unchanged, length 1.0 + # 2. [(0, 0), (1, 0)] - Clipped, length 1.0 + + lengths = clipped.geometry.length.sort_values().to_numpy() + assert len(lengths) == 2 + assert np.isclose(lengths[0], 1.0) + assert np.isclose(lengths[1], 1.0) + + def test_clip_graph_keep_outer(self, sample_crs: str) -> None: + """Test clipping with keep_outer_neighbors=True (intersects).""" + clip_poly = Polygon([(0, 0), (1, 0), (1, 2), (0, 2)]) + gdf = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(0, 0), (2, 0)]), # partially inside (intersects) + LineString([(0.5, 0.5), (0.5, 1.5)]), # fully inside + LineString([(2, 0), (3, 0)]), # fully outside + ] + }, + crs=sample_crs, + ) + + clipped = utils.clip_graph(gdf, clip_poly, keep_outer_neighbors=True) + assert len(clipped) == 2 + + def test_clip_graph_geometry_handling(self, sample_crs: str) -> None: + """Test that MultiLineStrings are exploded.""" + clip_poly = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]) + gdf = gpd.GeoDataFrame( + { + "geometry": [ + MultiLineString([LineString([(1, 1), (2, 2)]), LineString([(3, 3), (4, 4)])]) + ] + }, + crs=sample_crs, + ) + + clipped = utils.clip_graph(gdf, clip_poly) + assert isinstance(clipped, gpd.GeoDataFrame) + assert len(clipped) == 2 + assert all(isinstance(g, LineString) for g in clipped.geometry) + + def test_clip_graph_empty_input(self, sample_crs: str) -> None: + """Test with empty input GeoDataFrame.""" + empty_gdf = gpd.GeoDataFrame(geometry=[], crs=sample_crs) + clip_poly = Polygon([(0, 0), (1, 1), (1, 0)]) + + result = utils.clip_graph(empty_gdf, clip_poly) + assert isinstance(result, gpd.GeoDataFrame) + assert result.empty + + def test_clip_graph_area_as_gdf(self, sample_crs: str) -> None: + """Test passing area as GeoDataFrame.""" + clip_poly = Polygon([(0, 0), (1, 0), (1, 2), (0, 2)]) + area_gdf = gpd.GeoDataFrame({"geometry": [clip_poly]}, crs=sample_crs) + + gdf = gpd.GeoDataFrame({"geometry": [LineString([(0.5, 0.5), (0.5, 1.5)])]}, crs=sample_crs) + + clipped = utils.clip_graph(gdf, area_gdf) + assert len(clipped) == 1 + + def test_clip_graph_with_tuple_input(self, sample_crs: str) -> None: + """Test clipping with (nodes, edges) tuple input.""" + clip_poly = Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + + nodes = gpd.GeoDataFrame( + {"geometry": [Point(0, 0), Point(1, 1), Point(3, 3)]}, + index=pd.Index([1, 2, 3], name="node_id"), + crs=sample_crs, + ) + edges = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 1)]), LineString([(1, 1), (3, 3)])]}, + index=pd.MultiIndex.from_tuples([(1, 2), (2, 3)], names=["u", "v"]), + crs=sample_crs, + ) + + clipped_nodes, clipped_edges = utils.clip_graph((nodes, edges), clip_poly) + assert isinstance(clipped_nodes, gpd.GeoDataFrame) + assert isinstance(clipped_edges, gpd.GeoDataFrame) + assert len(clipped_edges) == 2 # Both edges now kept (one clipped) + assert len(clipped_nodes) == 3 # All nodes connected to kept edges + + def test_clip_graph_with_nx_input(self, sample_crs: str) -> None: + """Test clipping with NetworkX graph input.""" + clip_poly = Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + + nodes = gpd.GeoDataFrame( + {"geometry": [Point(0, 0), Point(1, 1), Point(3, 3)]}, + index=pd.Index([1, 2, 3], name="node_id"), + crs=sample_crs, + ) + edges = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 1)]), LineString([(1, 1), (3, 3)])]}, + index=pd.MultiIndex.from_tuples([(1, 2), (2, 3)], names=["u", "v"]), + crs=sample_crs, + ) + nx_graph = utils.gdf_to_nx(nodes=nodes, edges=edges) + + result = utils.clip_graph(nx_graph, clip_poly) + assert isinstance(result, nx.Graph) + + def test_clip_graph_as_nx_output(self, sample_crs: str) -> None: + """Test clipping with as_nx=True returns NetworkX graph.""" + clip_poly = Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + gdf = gpd.GeoDataFrame( + {"geometry": [LineString([(0.5, 0.5), (1, 1)])]}, + crs=sample_crs, + ) + + result = utils.clip_graph(gdf, clip_poly, as_nx=True) + assert isinstance(result, nx.Graph) + + +class TestRemoveIsolatedComponents(BaseGraphTest): + """Test remove_isolated_components functionality.""" + + def test_remove_isolated_basic(self, sample_crs: str) -> None: + """Test basic isolation removal with GeoDataFrame.""" + # Create a graph with two disconnected components + gdf = gpd.GeoDataFrame( + { + "geometry": [ + # Large component (3 edges) + LineString([(0, 0), (1, 0)]), + LineString([(1, 0), (2, 0)]), + LineString([(2, 0), (3, 0)]), + # Small isolated component (1 edge) + LineString([(10, 10), (11, 10)]), + ] + }, + crs=sample_crs, + ) + + result = utils.remove_isolated_components(gdf) + assert isinstance(result, gpd.GeoDataFrame) + assert len(result) == 3 # Only large component kept + + def test_remove_isolated_with_tuple_input(self, sample_crs: str) -> None: + """Test with (nodes, edges) tuple input.""" + nodes = gpd.GeoDataFrame( + {"geometry": [Point(0, 0), Point(1, 0), Point(10, 10), Point(11, 10)]}, + index=pd.Index([1, 2, 3, 4], name="node_id"), + crs=sample_crs, + ) + edges = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(0, 0), (1, 0)]), # Component 1 + LineString([(10, 10), (11, 10)]), # Component 2 (isolated) + ] + }, + index=pd.MultiIndex.from_tuples([(1, 2), (3, 4)], names=["u", "v"]), + crs=sample_crs, + ) + + result_nodes, result_edges = utils.remove_isolated_components((nodes, edges)) + assert isinstance(result_nodes, gpd.GeoDataFrame) + assert isinstance(result_edges, gpd.GeoDataFrame) + # Both components have same size (1 edge), first one should be kept + assert len(result_edges) == 1 + + def test_remove_isolated_with_nx_input(self, sample_crs: str) -> None: + """Test with NetworkX graph input.""" + nodes = gpd.GeoDataFrame( + {"geometry": [Point(0, 0), Point(1, 0), Point(10, 10)]}, + index=pd.Index([1, 2, 3], name="node_id"), + crs=sample_crs, + ) + edges = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 0)])]}, + index=pd.MultiIndex.from_tuples([(1, 2)], names=["u", "v"]), + crs=sample_crs, + ) + nx_graph = utils.gdf_to_nx(nodes=nodes, edges=edges) + # Add isolated node + nx_graph.add_node(999, pos=(10, 10), geometry=Point(10, 10)) + + result = utils.remove_isolated_components(nx_graph) + assert isinstance(result, nx.Graph) + + def test_remove_isolated_as_nx_output(self, sample_crs: str) -> None: + """Test with as_nx=True returns NetworkX graph.""" + gdf = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 0)])]}, + crs=sample_crs, + ) + + result = utils.remove_isolated_components(gdf, as_nx=True) + assert isinstance(result, nx.Graph) + + def test_remove_isolated_empty_input(self, sample_crs: str) -> None: + """Test with empty GeoDataFrame.""" + empty_gdf = gpd.GeoDataFrame(geometry=[], crs=sample_crs) + + result = utils.remove_isolated_components(empty_gdf) + assert isinstance(result, gpd.GeoDataFrame) + assert result.empty + + def test_remove_isolated_graph_conversion_error(self, sample_crs: str) -> None: + """Test remove_isolated_components when graph conversion fails (covers lines 4936-4940).""" + # Create a GeoDataFrame with geometry but invalid structure for graph conversion + edges = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 0)])]}, + crs=sample_crs, + ) + # Set a non-standard index that will cause issues + edges.index = pd.Index(["edge1"], name="weird_id") + + # Should handle the error gracefully + result = utils.remove_isolated_components(edges) + assert isinstance(result, gpd.GeoDataFrame) + + def test_remove_isolated_invalid_graph_structure(self, sample_crs: str) -> None: + """Test remove_isolated_components with edges that fail graph conversion.""" + # Create a GeoDataFrame with geometry but non-standard index + edges = gpd.GeoDataFrame( + {"geometry": [LineString([(0, 0), (1, 0)])]}, + crs=sample_crs, + ) + # No MultiIndex, just a simple index that won't convert to graph properly + edges.index = pd.Index(["edge1"], name="weird_id") + + # Should handle gracefully + result = utils.remove_isolated_components(edges) + assert isinstance(result, gpd.GeoDataFrame) + + def test_nx_to_gdf_pos_from_xy_attributes(self) -> None: + """Test nx_to_gdf with pos populated from x/y attributes (covers line 2237).""" + G = nx.Graph() + G.add_node(1, x=0.0, y=0.0) + G.add_node(2, x=1.0, y=1.0) + G.add_edge(1, 2) + G.graph = {"crs": "EPSG:27700", "is_hetero": False} + + # Should populate 'pos' from x/y when calling nx_to_gdf + nodes_gdf = nx_to_gdf(G, nodes=True, edges=False) + assert isinstance(nodes_gdf, gpd.GeoDataFrame) + assert len(nodes_gdf) == 2 + + def test_concave_hull_knn_two_points(self) -> None: + """Test concave_hull_knn with 2 points returns LineString (covers line 3000).""" + points = [Point(0, 0), Point(1, 1)] + result = utils._concave_hull_knn(points, k=10) + # With 2 points, should return a LineString + assert result.geom_type == "LineString" + + def test_concave_hull_knn_one_point(self) -> None: + """Test concave_hull_knn with 1 point returns Point (covers line 3000).""" + points = [Point(0, 0)] + result = utils._concave_hull_knn(points, k=10) + # With 1 point, should return a Point + assert result.geom_type == "Point" + + def test_concave_hull_alpha_degenerate(self) -> None: + """Test _concave_hull_alpha with 2 points (covers line 3300).""" + points = [Point(0, 0), Point(1, 1)] + result = utils._concave_hull_alpha(points, ratio=0.5, allow_holes=False) + # Should fallback to convex hull + assert result.geom_type in ["Polygon", "LineString", "Point"] + + def test_isochrone_buffer_returns_non_polygon(self) -> None: + """Test create_isochrone handles non-polygon geometry from buffer.""" + # Create a simple graph that might result in a geometry collection + G = nx.Graph() + G.add_node(1, pos=(0, 0), geometry=Point(0, 0)) + G.graph = {"crs": "EPSG:27700"} + + result = utils.create_isochrone( + G, + center_point=Point(0, 0), + threshold=10, + method="buffer", + buffer_distance=0.01, + ) + # Result should always be Polygon or MultiPolygon + assert result.geometry.iloc[0].geom_type in ["Polygon", "MultiPolygon"] diff --git a/uv.lock b/uv.lock index 6cee6ed..c6026fb 100644 --- a/uv.lock +++ b/uv.lock @@ -522,6 +522,7 @@ version = "0.2.3" source = { editable = "." } dependencies = [ { name = "geopandas" }, + { name = "geopy" }, { name = "libpysal" }, { name = "momepy" }, { name = "networkx" }, @@ -617,6 +618,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "geopandas", specifier = ">=1.1.1" }, + { name = "geopy", specifier = ">=2.4.0" }, { name = "libpysal", specifier = ">=4.12.1" }, { name = "momepy" }, { name = "networkx", specifier = ">=2.8" }, @@ -1137,11 +1139,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2025.12.0" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/27/954057b0d1f53f086f681755207dda6de6c660ce133c829158e8e8fe7895/fsspec-2025.12.0.tar.gz", hash = "sha256:c505de011584597b1060ff778bb664c1bc022e87921b0e4f10cc9c44f9635973", size = 309748, upload-time = "2025-12-03T15:23:42.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] @@ -3610,8 +3612,8 @@ wheels = [ [[package]] name = "overturemaps" -version = "0.17.0" -source = { git = "https://github.com/OvertureMaps/overturemaps-py.git#317e18eee13c59e740756733b1376d6bd03fa71b" } +version = "0.18.1" +source = { git = "https://github.com/OvertureMaps/overturemaps-py.git#600480812f687bbcbfef3430ba0ef86e28b6e8a0" } dependencies = [ { name = "click" }, { name = "numpy" }, @@ -5085,10 +5087,10 @@ dependencies = [ { name = "typing-extensions", marker = "(sys_platform == 'darwin' and extra == 'extra-10-city2graph-cpu') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu118') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu128' and extra == 'extra-10-city2graph-cu130')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:a52952a8c90a422c14627ea99b9826b7557203b46b4d0772d3ca5c7699692425" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:287242dd1f830846098b5eca847f817aa5c6015ea57ab4c1287809efea7b77eb" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8924d10d36eac8fe0652a060a03fc2ae52980841850b9a1a2ddb0f27a4f181cd" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:bcee64ae7aa65876ceeae6dcaebe75109485b213528c74939602208a20706e3f" }, ] [[package]] @@ -5145,21 +5147,21 @@ dependencies = [ { name = "typing-extensions", marker = "(sys_platform != 'darwin' and extra == 'extra-10-city2graph-cpu') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu118') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu128' and extra == 'extra-10-city2graph-cu130')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-win_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-win_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0e611cfb16724e62252b67d31073bc5c490cb83e92ecdc1192762535e0e44487" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3de2adb9b4443dc9210ef1f1b16da3647ace53553166d6360bbbd7edd6f16e4d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:69b3785d28be5a9c56ab525788ec5000349ec59132a74b7d5e954b905015b992" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:15b4ae6fe371d96bffb8e1e9af62164797db20a0dc1337345781659cfd0b8bb1" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf9b442a51a2948e41216a76d7ab00f0694cfcaaa51b6f9bcab57b7f89843e6" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7417d8c565f219d3455654cb431c6d892a3eb40246055e14d645422de13b9ea1" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a4e06b4f441675d26b462123c8a83e77c55f1ec8ebc081203be2db1ea8054add" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:1abe31f14b560c1f062699e966cb08ef5b67518a1cfac2d8547a3dbcd8387b06" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:404a7ab2fffaf2ca069e662f331eb46313692b2f1630df2720094284f390ccef" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:161decbff26a33f13cb5ba6d2c8f458bbf56193bcc32ecc70be6dd4c7a3ee79d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:e586ab1363e3f86aa4cc133b7fdcf98deb1d2c13d43a7a6e5a6a18e9c5364893" }, ] [[package]] @@ -5196,18 +5198,18 @@ dependencies = [ { name = "typing-extensions", marker = "(sys_platform == 'linux' and extra == 'extra-10-city2graph-cu126') or (sys_platform == 'win32' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu118') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu128' and extra == 'extra-10-city2graph-cu130')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a1641ad5278e8d830f31eee2f628627d42c53892e1770d1d1e1c475576d327f7" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:57e4f908dda76a6d5bf7138727d95fcf7ce07115bc040e7ed541d9d25074b28d" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp311-cp311-win_amd64.whl", hash = "sha256:8afd366413aeb51a4732042719f168fae6f4c72326e59e9bdbe20a5c5be52418" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a4fc209b36bd4752db5370388b0ffaab58944240de36a2c0f88129fcf4b07eb2" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:67e9b1054f435d33af6fa67343f93d73dc2d37013623672d6ffb24ce39b666c2" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp312-cp312-win_amd64.whl", hash = "sha256:f2f1c68c7957ed8b6b56fc450482eb3fa53947fb74838b03834a1760451cf60f" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad4eb85330a6b7db124462d7e9e00dea3c150e96ca017cc53c4335625705a7a2" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f58a36f53f6bf24312d5d548b640062c99a40394fcb7d0c5a70375aa5be31628" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313-win_amd64.whl", hash = "sha256:625703f377a53e20cade81291ac742f044ea46a1e86a3949069e62e321025ba3" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8433729c5cf0f928ba4dd43adb3509e6faadd223f0f11028841af025e8721b18" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ad0d5dd90f8e43c5a739e662b0542448e36968002efc4c2a11c5ad3b01faf04b" }, + { url = "https://download.pytorch.org/whl/cu126/torch-2.9.1%2Bcu126-cp313-cp313t-win_amd64.whl", hash = "sha256:2985f3ca723da9f8bc596b38698946a394a0cab541f008ac5bcf5b36696d4ecb" }, ] [[package]] @@ -5244,18 +5246,18 @@ dependencies = [ { name = "typing-extensions", marker = "(sys_platform == 'linux' and extra == 'extra-10-city2graph-cu128') or (sys_platform == 'win32' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu128' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu118') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu130')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cf4ad82430824a80a9f398e29369524ed26c152cf00c2c12002e5400b35e260d" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2a1da940f0757621d098c9755f7504d791a72a40920ec85a4fd98b20253fca4e" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:633005a3700e81b5be0df2a7d3c1d48aced23ed927653797a3bd2b144a3aeeb6" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1176f250311fa95cc3bca8077af323e0d73ea385ba266e096af82e7e2b91f256" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7cb4018f4ce68b61fd3ef87dc1c4ca520731c7b5b200e360ad47b612d7844063" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:3a01f0b64c10a82d444d9fd06b3e8c567b1158b76b2764b8f51bfd8f535064b0" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b80b7555dcd0a75b7b06016991f01281a0bb078cf28fa2d1dfb949fad2fbd07" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:63381a109a569b280ed3319da89d3afe5cf9ab5c879936382a212affb5c90552" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313-win_amd64.whl", hash = "sha256:ad9183864acdd99fc5143d7ca9d3d2e7ddfc9a9600ff43217825d4e5e9855ccc" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2314521c74d76e513c53bb72c0ce3511ef0295ff657a432790df6c207e5d7962" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4454a4faca31af81566e3a4208f10f20b8a6d9cfe42791b0ca7ff134326468fc" }, + { url = "https://download.pytorch.org/whl/cu128/torch-2.9.1%2Bcu128-cp313-cp313t-win_amd64.whl", hash = "sha256:24420e430e77136f7079354134b34e7ba9d87e539f5ac84c33b08e5c13412ebe" }, ] [[package]] @@ -5292,18 +5294,18 @@ dependencies = [ { name = "typing-extensions", marker = "(sys_platform == 'linux' and extra == 'extra-10-city2graph-cu130') or (sys_platform == 'win32' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu128' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu118') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cpu' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu124') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu118' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu126') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu124' and extra == 'extra-10-city2graph-cu130') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu128') or (extra == 'extra-10-city2graph-cu126' and extra == 'extra-10-city2graph-cu130')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fd6c7d297e21758a7fa07624f2b5bb15607ee3b1dcc52519e8e796c6d4fcf960" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f40778951ca1533dc634b3842392641fa0b641181ff2f71d62728ef33cc36a5c" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp311-cp311-win_amd64.whl", hash = "sha256:8db2814e63f2b365bda88526587ca75a6083a0b957a24b2b0d45ddc5ee350176" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6e7f84cb10c7e7d9f862c318f056d64840544ab4f0bcbf8cf7ed6047fe04051f" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e70e1b18881e6b3c1ce402d0a989da39f956a3a057526e03c354df23d704ce9b" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:cd3232a562ad2a2699d48130255e1b24c07dfe694a40dcd24fad683c752de121" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:32b47b951a9f4e8dc7e132bc2e2f60ac6dd236c5786c07320185bc5062ce5b92" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bdec3d5e26a472d292ec48d9d56e3feda86f3e60744cb49c92603502ba34d331" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:47cae56331fdec7f7082aad967b25868cc5f94b203fc273e40346a852aa009dd" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:4444e1e23b083e3dc0591e53d3a3f7ba45500a6e24071ffc8df2dc319cbd6302" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:5270e343fc4b1d21308a30656ea922a23d85f3afbf12abd2481672fa94953fd6" }, + { url = "https://download.pytorch.org/whl/cu130/torch-2.9.1%2Bcu130-cp313-cp313t-win_amd64.whl", hash = "sha256:c0bb31481c3f505cb5baa630f2b975f07f77be557bbafe5cfc695ac911ea325c" }, ] [[package]]