diff --git a/dataretrieval/ogc/shaping.py b/dataretrieval/ogc/shaping.py index b8e3c8e5..4df242f2 100644 --- a/dataretrieval/ogc/shaping.py +++ b/dataretrieval/ogc/shaping.py @@ -31,6 +31,12 @@ logger = logging.getLogger(__name__) +# Water Data OGC coordinates are published in WGS84, so attach that CRS where the +# GeoDataFrame is built (otherwise the result is CRS-naive and ``to_crs`` / +# spatial joins fail). Mirrors the constants in ``nldi`` (EPSG:4326) and ``nwis`` +# (EPSG:4269). +_CRS = "EPSG:4326" + # Whether geopandas is present is a static, environment-level fact, so warn # once here at import time rather than per query/chunk. if not GEOPANDAS: @@ -141,7 +147,8 @@ def _get_resp_data( # a plain DataFrame. Features that already carry geometry (the common # sites case) are passed through without a per-feature dict copy. df = gpd.GeoDataFrame.from_features( - [f if "geometry" in f else {**f, "geometry": None} for f in features] + [f if "geometry" in f else {**f, "geometry": None} for f in features], + crs=_CRS, ) # Mirror the non-geopandas branch's defensive ``f.get("id")`` so a feature # missing a top-level ``id`` yields None rather than a KeyError. diff --git a/dataretrieval/waterdata/stats.py b/dataretrieval/waterdata/stats.py index 5514bb50..2aa357de 100644 --- a/dataretrieval/waterdata/stats.py +++ b/dataretrieval/waterdata/stats.py @@ -22,6 +22,7 @@ _run_sync, ) from dataretrieval.ogc.shaping import ( + _CRS, GEOPANDAS, _attach_coordinates, _empty_feature_frame, @@ -111,7 +112,8 @@ def _handle_nesting( # can't ``KeyError`` on a stats feature that omits geometry — mirrors # the guard in :func:`engine._get_resp_data`. df = gpd.GeoDataFrame.from_features( - [f if "geometry" in f else {**f, "geometry": None} for f in features] + [f if "geometry" in f else {**f, "geometry": None} for f in features], + crs=_CRS, ).drop(columns=["data"], errors="ignore") # Unnest json features, properties, data, and values while retaining necessary diff --git a/tests/waterdata_utils_test.py b/tests/waterdata_utils_test.py index faf600bd..95aabe30 100644 --- a/tests/waterdata_utils_test.py +++ b/tests/waterdata_utils_test.py @@ -596,6 +596,58 @@ class _Sentinel: assert isinstance(result, _Sentinel) +def test_get_resp_data_attaches_wgs84_crs(): + """A geometry-bearing Water Data page should come back tagged as + EPSG:4326 (the CRS the coordinates are published in), so callers can + run ``to_crs`` / spatial joins without first patching in a CRS by + hand. Regression for the ``.crs is None`` reported in issue #342.""" + geopandas = pytest.importorskip("geopandas") + + resp = _resp_ok( + [ + { + "type": "Feature", + "id": "USGS-01", + "geometry": {"type": "Point", "coordinates": [-76.5, 39.2]}, + "properties": {"monitoring_location_id": "USGS-01"}, + } + ] + ) + df = _get_resp_data(resp, geopd=True) + assert isinstance(df, geopandas.GeoDataFrame) + assert df.crs == "EPSG:4326" + + +def test_handle_nesting_attaches_wgs84_crs(): + """The stats path builds its GeoDataFrame the same way, so it should + carry EPSG:4326 too (issue #342).""" + geopandas = pytest.importorskip("geopandas") + + body = { + "next": None, + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-76.5, 39.2]}, + "properties": { + "monitoring_location_id": "USGS-01", + "data": [ + { + "parameter_code": "00060", + "unit_of_measure": "ft^3/s", + "parent_time_series_id": "ts-1", + "values": [{"statistic_id": "mean", "value": 10.0}], + } + ], + }, + } + ], + } + df = _handle_nesting(body, geopd=True) + assert isinstance(df, geopandas.GeoDataFrame) + assert df.crs == "EPSG:4326" + + def test_handle_nesting_tolerates_missing_features_key(): """A 200 response with a body that doesn't carry ``features`` at all (rare but seen in error envelopes) must also short-circuit