Source code for closecity.spatial

"""Turn Close API replies into GeoDataFrames.

The client does this for you in the default ``spatial`` output mode; this module
is the machinery, and :func:`to_geopandas` is also usable by hand. It builds on
the row-shaping in :mod:`closecity.tabular` and adds geometry. Three response
shapes are recognised automatically:

* **POI rows** (``lat`` / ``lon``) from :meth:`Client.pois_search`,
  :meth:`block_pois`, :meth:`point_pois`, :meth:`poi`, and :meth:`places` become
  **point** geometry, built offline.
* **Isochrone** replies with ``format=geojson`` from :meth:`Client.isochrone` become
  **polygon** geometry, built offline from the ``features`` envelope (a custom
  object, not a bare FeatureCollection).
* **Block rows** (``geoid`` only) from :meth:`blocks_query`, :meth:`place_blocks`,
  :meth:`poi_catchment`, and isochrone ``format=blocks`` become **polygons** by
  joining census-block boundaries. Pass them as ``block_geometry`` (a GeoDataFrame
  keyed on ``geoid_col``), or let ``fetch=True`` download them with ``pygris``
  (the ``tiger`` extra).
"""

from __future__ import annotations

from typing import Any

__all__ = ["to_geopandas"]


def _require_geopandas():
    # geopandas is a required dependency; this import always succeeds.
    import geopandas as gpd
    from shapely.geometry import Point, shape
    return gpd, Point, shape


def _isochrone_gdf(data: dict, gpd, shape, crs):
    geoms, records = [], []
    for feature in data.get("features") or []:
        geom = feature.get("geometry")
        geoms.append(shape(geom) if geom else None)
        records.append(dict(feature.get("properties") or {}))
    return gpd.GeoDataFrame(records, geometry = geoms, crs = crs)


def _points_gdf(rows: list[dict], gpd, Point, crs):
    geoms = [
        Point(r["lon"], r["lat"])
        if r.get("lon") is not None and r.get("lat") is not None
        else None
        for r in rows
    ]
    return gpd.GeoDataFrame(list(rows), geometry = geoms, crs = crs)


def _fetch_blocks(geoids: list[str], geoid_col: str):
    try:
        import pandas as pd
        import pygris
    except ImportError as exc:
        raise ImportError(
            "Mapping blocks needs pygris. Install: pip install 'closecity[tiger]', "
            "or use output='tabular' for the same rows without geometry."
        ) from exc
    pairs = sorted({(g[:2], g[2:5]) for g in geoids if g})
    frames = [pygris.blocks(state = s, county = c, year = 2020, cache = True)
              for s, c in pairs]
    gdf = pd.concat(frames, ignore_index = True)
    if geoid_col not in gdf.columns and "GEOID20" in gdf.columns:
        gdf = gdf.rename(columns = {"GEOID20": geoid_col})
    return gdf


def _blocks_gdf(rows, block_geometry, geoid_col, crs, fetch, gpd):
    import pandas as pd

    df = pd.DataFrame(list(rows))
    if block_geometry is None:
        if fetch:
            block_geometry = _fetch_blocks([r.get("geoid") for r in rows], geoid_col)
        else:
            raise ValueError(
                "Block replies carry only GEOIDs. Pass block_geometry=<GeoDataFrame> "
                "(joined on `geoid_col`, default 'GEOID20'), or fetch=True to pull TIGER "
                "blocks with pygris."
            )
    geo = block_geometry.rename(columns = {geoid_col: "geoid"})
    # TIGER blocks arrive in NAD83 (EPSG:4269); reproject so they match the POI and
    # isochrone geometry (EPSG:4326) and can be combined without a CRS mismatch.
    if crs is not None and geo.crs is not None and str(geo.crs) != str(crs):
        geo = geo.to_crs(crs)
    merged = df.merge(geo[["geoid", "geometry"]], on = "geoid", how = "left")
    out = gpd.GeoDataFrame(merged, geometry = "geometry", crs = crs)
    # Drop rows whose GEOID had no TIGER match (e.g. water blocks); they have no
    # geometry and would break plotting and spatial joins.
    return out[out.geometry.notna()].reset_index(drop = True)


[docs] def to_geopandas( source: Any, *, block_geometry: Any = None, geoid_col: str = "GEOID20", crs: Any = "EPSG:4326", fetch: bool = False, ): """Convert a Close API reply into a :class:`geopandas.GeoDataFrame`. ``source`` is a :class:`~closecity.Reply`, a :class:`~closecity.Paginator` (all pages are collected), or a raw response body. The geometry kind is detected from the payload; see the module docstring. ``block_geometry`` / ``geoid_col`` / ``fetch`` only apply to block replies. Metering and envelope metadata land on ``.attrs``. """ from . import tabular gpd, Point, shape = _require_geopandas() data, meta = tabular._collect(source) if tabular._is_isochrone(data): gdf = _isochrone_gdf(data, gpd, shape, crs) tabular._stamp_attrs(gdf, data, meta) return gdf rows, envelope = tabular._rows_and_envelope(data) first = rows[0] if rows else None if first is not None and "lat" in first and "lon" in first: gdf = _points_gdf(rows, gpd, Point, crs) elif first is not None and "geoid" in first: gdf = _blocks_gdf(rows, block_geometry, geoid_col, crs, fetch, gpd) elif isinstance(data, dict) and data.get("lat") is not None \ and data.get("lon") is not None: # A single POI detail body: the whole body is one point. gdf = _points_gdf([data], gpd, Point, crs) elif isinstance(data, dict) and isinstance(data.get("block"), dict) \ and data["block"].get("geoid"): # A summary body whose origin block carries the geometry. gdf = _blocks_gdf([data["block"]], block_geometry, geoid_col, crs, fetch, gpd) else: raise ValueError( "This reply has no lat/lon, isochrone features, or block GEOIDs to build " "geometry from." ) tabular._stamp_attrs(gdf, envelope, meta) return gdf