From ea22999d98d379fc91879780808688c749d8ca58 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 14 Jan 2026 12:03:21 -0700 Subject: [PATCH] Add pagination and data retrieval support for road and water features in Features module --- docs/guides/features.md | 92 +++++++++++- fastfuels_sdk/__init__.py | 2 + fastfuels_sdk/features.py | 295 ++++++++++++++++++++++++++++++++++++++ tests/test_features.py | 182 ++++++++++++++++++++++- 4 files changed, 564 insertions(+), 7 deletions(-) diff --git a/docs/guides/features.md b/docs/guides/features.md index e5505f2..312d77c 100644 --- a/docs/guides/features.md +++ b/docs/guides/features.md @@ -152,6 +152,76 @@ water = features.create_water_feature_from_osm(in_place=True) assert features.water is water ``` +## Retrieve Feature Data (GeoJSON) + +Once features are processed (status "completed"), you can retrieve the actual geometry and attribute data as GeoJSON. + +### Get Road Feature Data + +```python +# Wait for processing to complete +road.wait_until_completed() + +# Get first page of road data (default: 50 features per page) +data = road.get_data() +print(f"Page {data.current_page}, {len(data.features)} of {data.total_items} features") + +# Get specific page with custom size +data = road.get_data(page=1, size=100) + +# Get ALL road features at once (handles pagination automatically) +all_data = road.get_all_data() +print(f"Total features: {len(all_data.features)}") +``` + +### Get Water Feature Data + +```python +# Wait for processing to complete +water.wait_until_completed() + +# Get first page of water data +data = water.get_data() +print(f"Page {data.current_page}, {len(data.features)} of {data.total_items} features") + +# Get ALL water features at once +all_data = water.get_all_data() +print(f"Total features: {len(all_data.features)}") +``` + +### Convert to GeoDataFrame + +The feature data can be easily converted to a GeoPandas GeoDataFrame for analysis: + +```python +import geopandas as gpd + +# Get all road features +all_roads = road.get_all_data() + +# Convert to GeoDataFrame +gdf = gpd.GeoDataFrame.from_features( + [f.to_dict() for f in all_roads.features], + crs="EPSG:4326" +) + +# Now you can use all GeoPandas functionality +print(gdf.head()) +gdf.plot() +``` + +### Pagination Details + +- `page`: Zero-indexed page number (default: 0) +- `size`: Number of features per page (1-1000, default: 50) +- `get_all_data()`: Automatically iterates through all pages and returns combined results + +The response includes pagination metadata: +- `current_page`: Current page number +- `page_size`: Features per page +- `total_items`: Total number of features available +- `crs`: Coordinate reference system information + ## Get Updated Feature Data ### Refresh All Features @@ -234,14 +304,24 @@ road.wait_until_completed(verbose=True) water = features.create_water_feature_from_osm(in_place=True) water.wait_until_completed(verbose=True) -# Get latest feature data -features.get(in_place=True) - -# Process based on feature status +# Retrieve the actual feature data as GeoJSON if features.road and features.road.status == "completed": - print("Road features ready") + road_data = features.road.get_all_data() + print(f"Retrieved {len(road_data.features)} road features") + if features.water and features.water.status == "completed": - print("Water features ready") + water_data = features.water.get_all_data() + print(f"Retrieved {len(water_data.features)} water features") + +# Convert to GeoDataFrame for analysis (optional) +import geopandas as gpd + +if road_data.features: + roads_gdf = gpd.GeoDataFrame.from_features( + [f.to_dict() for f in road_data.features], + crs="EPSG:4326" + ) + print(f"Roads GeoDataFrame shape: {roads_gdf.shape}") # Clean up when done if features.road: diff --git a/fastfuels_sdk/__init__.py b/fastfuels_sdk/__init__.py index 7ee25c6..696f0c6 100644 --- a/fastfuels_sdk/__init__.py +++ b/fastfuels_sdk/__init__.py @@ -1,6 +1,7 @@ from fastfuels_sdk.domains import Domain, list_domains from fastfuels_sdk.inventories import Inventories, TreeInventory from fastfuels_sdk.features import Features, RoadFeature, WaterFeature +from fastfuels_sdk.client_library.models import FeatureDataResponse from fastfuels_sdk.grids import ( Grids, SurfaceGrid, @@ -24,6 +25,7 @@ "Features", "RoadFeature", "WaterFeature", + "FeatureDataResponse", "Grids", "SurfaceGrid", "SurfaceGridBuilder", diff --git a/fastfuels_sdk/features.py b/fastfuels_sdk/features.py index c5b1b8f..ff2dbc1 100644 --- a/fastfuels_sdk/features.py +++ b/fastfuels_sdk/features.py @@ -23,6 +23,7 @@ RoadFeatureSource, WaterFeatureSource, Geojson, + FeatureDataResponse, ) @@ -577,6 +578,153 @@ def wait_until_completed( ) return road_feature + def get_data( + self, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> FeatureDataResponse: + """Get paginated road feature data as a GeoJSON FeatureCollection. + + Retrieves the actual road geometry and attribute data for this domain. + The road feature must be fully processed (status "completed") before + data can be retrieved. + + Parameters + ---------- + page : int, optional + Zero-indexed page number. Defaults to 0 if not specified. + size : int, optional + Number of features per page. Valid range: 1-1000. Defaults to 50. + + Returns + ------- + FeatureDataResponse + A paginated GeoJSON FeatureCollection containing: + - type: Always "FeatureCollection" + - features: List of GeoJSON Feature objects with road geometries + - current_page: The current page number + - page_size: Number of features per page + - total_items: Total number of road features available + - crs: Coordinate reference system information + + Raises + ------ + NotFoundException + If the domain or road feature does not exist. + UnprocessableEntityError + If the road feature is not yet completed (status != "completed"). + + Examples + -------- + >>> from fastfuels_sdk import Features + >>> features = Features.from_domain_id("abc123") + >>> road = features.create_road_feature_from_osm() + >>> road.wait_until_completed() + >>> + >>> # Get first page of data + >>> data = road.get_data() + >>> print(f"Page {data.current_page}, {len(data.features)} features") + >>> + >>> # Get specific page with custom size + >>> data = road.get_data(page=2, size=100) + + See Also + -------- + RoadFeature.get_all_data : Retrieve all road features across all pages + """ + return get_road_feature_api().get_road_feature_data( + domain_id=self.domain_id, + page=page, + size=size, + ) + + def get_all_data( + self, + size: int = 1000, + ) -> FeatureDataResponse: + """Get all road feature data by automatically paginating through all pages. + + Convenience method that retrieves all road features by iterating through + all pages and combining them into a single FeatureDataResponse. The road + feature must be fully processed (status "completed") before data can be + retrieved. + + Parameters + ---------- + size : int, optional + Number of features to retrieve per API call. Valid range: 1-1000. + Defaults to 1000 (maximum) for efficiency. + + Returns + ------- + FeatureDataResponse + A GeoJSON FeatureCollection containing all road features: + - type: Always "FeatureCollection" + - features: Complete list of all GeoJSON Feature objects + - current_page: Always 0 (represents combined result) + - page_size: Total number of features returned + - total_items: Total number of road features + - crs: Coordinate reference system information + + Raises + ------ + NotFoundException + If the domain or road feature does not exist. + UnprocessableEntityError + If the road feature is not yet completed (status != "completed"). + + Examples + -------- + >>> from fastfuels_sdk import Features + >>> features = Features.from_domain_id("abc123") + >>> road = features.create_road_feature_from_osm() + >>> road.wait_until_completed() + >>> + >>> # Get all road features at once + >>> all_data = road.get_all_data() + >>> print(f"Total features: {len(all_data.features)}") + >>> + >>> # Convert to GeoDataFrame for analysis + >>> import geopandas as gpd + >>> gdf = gpd.GeoDataFrame.from_features( + ... all_data.features, + ... crs="EPSG:4326" + ... ) + + See Also + -------- + RoadFeature.get_data : Retrieve a single page of road features + """ + # Get first page to determine total items + first_page = get_road_feature_api().get_road_feature_data( + domain_id=self.domain_id, + page=0, + size=size, + ) + + all_features = list(first_page.features) + total_items = first_page.total_items + + # Calculate remaining pages and fetch them + total_pages = (total_items + size - 1) // size + for page_num in range(1, total_pages): + page_data = get_road_feature_api().get_road_feature_data( + domain_id=self.domain_id, + page=page_num, + size=size, + ) + all_features.extend(page_data.features) + + # Return combined result + return FeatureDataResponse( + current_page=0, + page_size=len(all_features), + total_items=total_items, + crs=first_page.crs, + type="FeatureCollection", + features=all_features, + ) + def delete(self) -> None: """Delete these road features. @@ -736,6 +884,153 @@ def wait_until_completed( ) return water_feature + def get_data( + self, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> FeatureDataResponse: + """Get paginated water feature data as a GeoJSON FeatureCollection. + + Retrieves the actual water body geometry and attribute data for this domain. + The water feature must be fully processed (status "completed") before + data can be retrieved. + + Parameters + ---------- + page : int, optional + Zero-indexed page number. Defaults to 0 if not specified. + size : int, optional + Number of features per page. Valid range: 1-1000. Defaults to 50. + + Returns + ------- + FeatureDataResponse + A paginated GeoJSON FeatureCollection containing: + - type: Always "FeatureCollection" + - features: List of GeoJSON Feature objects with water body geometries + - current_page: The current page number + - page_size: Number of features per page + - total_items: Total number of water features available + - crs: Coordinate reference system information + + Raises + ------ + NotFoundException + If the domain or water feature does not exist. + UnprocessableEntityError + If the water feature is not yet completed (status != "completed"). + + Examples + -------- + >>> from fastfuels_sdk import Features + >>> features = Features.from_domain_id("abc123") + >>> water = features.create_water_feature_from_osm() + >>> water.wait_until_completed() + >>> + >>> # Get first page of data + >>> data = water.get_data() + >>> print(f"Page {data.current_page}, {len(data.features)} features") + >>> + >>> # Get specific page with custom size + >>> data = water.get_data(page=2, size=100) + + See Also + -------- + WaterFeature.get_all_data : Retrieve all water features across all pages + """ + return get_water_feature_api().get_water_feature_data( + domain_id=self.domain_id, + page=page, + size=size, + ) + + def get_all_data( + self, + size: int = 1000, + ) -> FeatureDataResponse: + """Get all water feature data by automatically paginating through all pages. + + Convenience method that retrieves all water features by iterating through + all pages and combining them into a single FeatureDataResponse. The water + feature must be fully processed (status "completed") before data can be + retrieved. + + Parameters + ---------- + size : int, optional + Number of features to retrieve per API call. Valid range: 1-1000. + Defaults to 1000 (maximum) for efficiency. + + Returns + ------- + FeatureDataResponse + A GeoJSON FeatureCollection containing all water features: + - type: Always "FeatureCollection" + - features: Complete list of all GeoJSON Feature objects + - current_page: Always 0 (represents combined result) + - page_size: Total number of features returned + - total_items: Total number of water features + - crs: Coordinate reference system information + + Raises + ------ + NotFoundException + If the domain or water feature does not exist. + UnprocessableEntityError + If the water feature is not yet completed (status != "completed"). + + Examples + -------- + >>> from fastfuels_sdk import Features + >>> features = Features.from_domain_id("abc123") + >>> water = features.create_water_feature_from_osm() + >>> water.wait_until_completed() + >>> + >>> # Get all water features at once + >>> all_data = water.get_all_data() + >>> print(f"Total features: {len(all_data.features)}") + >>> + >>> # Convert to GeoDataFrame for analysis + >>> import geopandas as gpd + >>> gdf = gpd.GeoDataFrame.from_features( + ... all_data.features, + ... crs="EPSG:4326" + ... ) + + See Also + -------- + WaterFeature.get_data : Retrieve a single page of water features + """ + # Get first page to determine total items + first_page = get_water_feature_api().get_water_feature_data( + domain_id=self.domain_id, + page=0, + size=size, + ) + + all_features = list(first_page.features) + total_items = first_page.total_items + + # Calculate remaining pages and fetch them + total_pages = (total_items + size - 1) // size + for page_num in range(1, total_pages): + page_data = get_water_feature_api().get_water_feature_data( + domain_id=self.domain_id, + page=page_num, + size=size, + ) + all_features.extend(page_data.features) + + # Return combined result + return FeatureDataResponse( + current_page=0, + page_size=len(all_features), + total_items=total_items, + crs=first_page.crs, + type="FeatureCollection", + features=all_features, + ) + def delete(self) -> None: """Delete these water features. diff --git a/tests/test_features.py b/tests/test_features.py index 103fdb2..bfe9cb0 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -9,7 +9,11 @@ # Internal imports from tests.utils import create_default_domain, normalize_datetime from fastfuels_sdk.features import Features, RoadFeature, WaterFeature -from fastfuels_sdk.client_library.models import RoadFeatureSource, WaterFeatureSource +from fastfuels_sdk.client_library.models import ( + RoadFeatureSource, + WaterFeatureSource, + FeatureDataResponse, +) from fastfuels_sdk.client_library.exceptions import NotFoundException # External imports @@ -38,6 +42,32 @@ def test_features(test_domain): # Cleanup handled by test_domain fixture +@pytest.fixture(scope="module") +def completed_road_feature(test_features): + """Fixture that creates a completed road feature for data retrieval tests""" + road_feature = test_features.create_road_feature(sources="OSM") + road_feature.wait_until_completed() + yield road_feature + # Cleanup: Delete the road feature after tests + try: + road_feature.delete() + except NotFoundException: + pass # Already deleted by another test + + +@pytest.fixture(scope="module") +def completed_water_feature(test_features): + """Fixture that creates a completed water feature for data retrieval tests""" + water_feature = test_features.create_water_feature(sources="OSM") + water_feature.wait_until_completed() + yield water_feature + # Cleanup: Delete the water feature after tests + try: + water_feature.delete() + except NotFoundException: + pass # Already deleted by another test + + class TestFeaturesFromDomain: def test_success(self, test_domain): """Test successful retrieval of features from a domain""" @@ -633,3 +663,153 @@ def test_delete_nonexistent_feature(self, test_features): with pytest.raises(NotFoundException): water_feature.delete() + + +class TestGetRoadFeatureData: + def test_get_data_default(self, completed_road_feature): + """Test getting road feature data with default parameters""" + data = completed_road_feature.get_data() + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.type == "FeatureCollection" + assert isinstance(data.features, list) + assert isinstance(data.current_page, int) + assert isinstance(data.page_size, int) + assert isinstance(data.total_items, int) + + def test_get_data_with_pagination(self, completed_road_feature): + """Test getting road feature data with pagination parameters""" + # Get first page with size 10 + data = completed_road_feature.get_data(page=0, size=10) + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.current_page == 0 + assert data.page_size <= 10 + + def test_get_data_nonexistent_domain(self, completed_road_feature): + """Test error handling when domain no longer exists""" + # Store original domain_id + original_id = completed_road_feature.domain_id + + # Set invalid domain_id + completed_road_feature.domain_id = uuid4().hex + + # Verify exception raised + with pytest.raises(NotFoundException): + completed_road_feature.get_data() + + # Restore original domain_id + completed_road_feature.domain_id = original_id + + +class TestGetAllRoadFeatureData: + def test_get_all_data_default(self, completed_road_feature): + """Test getting all road feature data""" + data = completed_road_feature.get_all_data() + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.type == "FeatureCollection" + assert isinstance(data.features, list) + assert len(data.features) == data.total_items + assert data.page_size == len(data.features) + + def test_get_all_data_with_custom_size(self, completed_road_feature): + """Test getting all road feature data with custom page size""" + data = completed_road_feature.get_all_data(size=5) + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert len(data.features) == data.total_items + + def test_get_all_data_nonexistent_domain(self, completed_road_feature): + """Test error handling when domain no longer exists""" + # Store original domain_id + original_id = completed_road_feature.domain_id + + # Set invalid domain_id + completed_road_feature.domain_id = uuid4().hex + + # Verify exception raised + with pytest.raises(NotFoundException): + completed_road_feature.get_all_data() + + # Restore original domain_id + completed_road_feature.domain_id = original_id + + +class TestGetWaterFeatureData: + def test_get_data_default(self, completed_water_feature): + """Test getting water feature data with default parameters""" + data = completed_water_feature.get_data() + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.type == "FeatureCollection" + assert isinstance(data.features, list) + assert isinstance(data.current_page, int) + assert isinstance(data.page_size, int) + assert isinstance(data.total_items, int) + + def test_get_data_with_pagination(self, completed_water_feature): + """Test getting water feature data with pagination parameters""" + # Get first page with size 10 + data = completed_water_feature.get_data(page=0, size=10) + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.current_page == 0 + assert data.page_size <= 10 + + def test_get_data_nonexistent_domain(self, completed_water_feature): + """Test error handling when domain no longer exists""" + # Store original domain_id + original_id = completed_water_feature.domain_id + + # Set invalid domain_id + completed_water_feature.domain_id = uuid4().hex + + # Verify exception raised + with pytest.raises(NotFoundException): + completed_water_feature.get_data() + + # Restore original domain_id + completed_water_feature.domain_id = original_id + + +class TestGetAllWaterFeatureData: + def test_get_all_data_default(self, completed_water_feature): + """Test getting all water feature data""" + data = completed_water_feature.get_all_data() + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert data.type == "FeatureCollection" + assert isinstance(data.features, list) + assert len(data.features) == data.total_items + assert data.page_size == len(data.features) + + def test_get_all_data_with_custom_size(self, completed_water_feature): + """Test getting all water feature data with custom page size""" + data = completed_water_feature.get_all_data(size=5) + + assert data is not None + assert isinstance(data, FeatureDataResponse) + assert len(data.features) == data.total_items + + def test_get_all_data_nonexistent_domain(self, completed_water_feature): + """Test error handling when domain no longer exists""" + # Store original domain_id + original_id = completed_water_feature.domain_id + + # Set invalid domain_id + completed_water_feature.domain_id = uuid4().hex + + # Verify exception raised + with pytest.raises(NotFoundException): + completed_water_feature.get_all_data() + + # Restore original domain_id + completed_water_feature.domain_id = original_id